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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
|
// Two things about a feed you leave and come back to.
//
// A backgrounded tab is frozen by the phone: timers stop and the relay
// sockets are cut, so every post made while you were away is simply missing
// and the feed sits there looking stale until you pull it down by hand. It
// catches up by itself now.
//
// And a post that lands while you are reading halfway down must not shove the
// page under your thumb. It waits behind a pill that says how many, and the
// tap that shows them takes you up to them.
//
// Run: bun tools/feed-live-test.js
import puppeteer from 'puppeteer-core';
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 { 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; };
// A real key, because a post arriving on a subscription is signature-checked
// on the way in — an unsigned fake would be dropped and prove nothing.
const SK = generateSecretKey();
const AUTHOR = getPublicKey(SK);
const signed = (content, ago) => finalizeEvent(
{ kind: 1, created_at: Math.floor(Date.now() / 1000) - ago, tags: [], content }, SK);
// enough to scroll past; these ride in through the cache, which is trusted
const seeded = Array.from({ length: 25 }, (_, i) => signed('post number ' + (i + 1), 600 + i * 60));
const html = await buildHtml({ minify: true, pwa: false });
// a picture that takes its time, so a post showing it must have waited
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
const server = Bun.serve({ port: 5296, fetch: async (req) => {
if (new URL(req.url).pathname === '/slow.png') {
await sleep(700);
return new Response(PNG, { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } });
}
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();
await page.evaluateOnNewDocument(() => {
window.__feedReqs = 0;
const subs = []; // [socket, subId] for every open kind-1 subscription
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' && m[2] && Array.isArray(m[2].kinds) && m[2].kinds.includes(1)) {
window.__feedReqs++;
subs.push([ws, m[1]]);
// A catch-up query (no since, no until) is answered from the queue
// the test filled, ahead of the real relay's end-of-stream.
if (!m[2].since && !m[2].until && (window.__catchup || []).length) {
const evs = window.__catchup; window.__catchup = [];
setTimeout(() => { for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); }, 0);
}
}
} catch {}
return send(data);
};
return ws;
};
window.WebSocket.prototype = Real.prototype;
Object.assign(window.WebSocket, Real);
// Hand a real event to the app exactly as a relay would: down an open
// subscription, through the signature gate, into mergeFeed.
window.__inject = (ev) => {
let n = 0;
for (const [ws, id] of subs) {
if (ws.readyState !== 1) continue;
ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
n++;
}
return n;
};
});
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; };
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5296/', { 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('import existing'); await sleep(400);
await page.waitForSelector('textarea');
const mn = generateMnemonic(wordlist);
await page.type('textarea', mn);
await click('open wallet');
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));
}, [base, AUTHOR, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
await sleep(1000);
await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
await sleep(4000);
check('the feed has posts in it', (await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length)) > 0);
console.log('\n[a post arriving while you read]');
// scroll down: this is someone reading, not someone sitting at the top
await page.evaluate(() => window.scrollTo(0, 900));
await sleep(500);
const before = await page.evaluate(() => {
const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => n.getBoundingClientRect().bottom > 120);
return { y: Math.round(window.scrollY), text: r.innerText.split('\n').find((l) => /post number/.test(l)), top: Math.round(r.getBoundingClientRect().top) };
});
check('...reading further down', before.y > 400, 'scrollY ' + before.y);
// a brand new post lands on the live subscription
const fresh = signed('BRAND NEW POST', 0);
const delivered = await page.evaluate((ev) => window.__inject(ev), fresh);
check('...a post really arrives on a live subscription', delivered > 0, delivered + ' socket(s)');
// A post waits at the door for its author's face and its pictures (or the
// two-second deadline, for a face nobody has) before it goes in — so give
// it that long, and no longer.
await sleep(2600);
const after = await page.evaluate((text) => {
const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => n.innerText.includes(text));
return { y: Math.round(window.scrollY), top: r ? Math.round(r.getBoundingClientRect().top) : null,
pill: (document.querySelector('.feed-new-pill') || {}).textContent || '',
inFeed: /BRAND NEW POST/.test(document.querySelector('.notes-feed')?.innerText || ''),
first: (document.querySelector('.notes-feed > .row') || {}).innerText || '' };
}, before.text);
check('it goes into the feed at once, at the top', after.inFeed && /BRAND NEW POST/.test(after.first));
check('...and the post you were reading has not moved', after.top === before.top, `top ${before.top} → ${after.top} (scrollY ${before.y} → ${after.y})`);
check('...and a pill says it is up there', /1 new post/i.test(after.pill), after.pill || 'no pill');
await page.evaluate(() => document.querySelector('.feed-new-pill').click());
await sleep(600);
const opened = await page.evaluate(() => ({ y: Math.round(window.scrollY), pill: !!document.querySelector('.feed-new-pill') }));
check('tapping it takes you up to the post', opened.y < 120, 'scrollY ' + opened.y);
check('...and the pill is done', !opened.pill);
console.log('\n[scrolling back up on your own]');
await page.evaluate(() => window.scrollTo(0, 900));
await sleep(400);
await page.evaluate((ev) => window.__inject(ev), signed('ANOTHER ONE', 0));
await sleep(2600);
check('a pill again, the post already in', !!(await page.$('.feed-new-pill'))
&& /ANOTHER ONE/.test(await page.evaluate(() => document.querySelector('.notes-feed')?.innerText || '')));
await page.evaluate(() => window.scrollTo(0, 0));
await sleep(900);
check('...that reaching the top clears by itself', !(await page.$('.feed-new-pill')));
console.log('\n[a post arrives whole, at the top]');
// At the top of the feed a live post opens itself — no pill. It waits at
// the door for its picture first, and the first time the row exists its
// image is already decoded. A row used to paint first and fill in after.
await page.evaluate(() => {
window.__firstSight = null;
const feed = document.querySelector('.notes-feed');
const look = () => {
if (window.__firstSight) return;
const row = [...feed.querySelectorAll('.row')].find((n) => /PICTURE POST/.test(n.innerText || ''));
if (!row) return;
const img = row.querySelector('img.note-img');
window.__firstSight = { img: !!img, ready: !!(img && img.complete && img.naturalWidth > 0), at: performance.now() };
// a fresh <img> node reports complete a beat after it is inserted, even
// from cache — what matters is that it is drawn on its first frame
const t = performance.now();
const poll = () => { if (img && img.complete && img.naturalWidth > 0) window.__firstSight.readyMs = Math.round(performance.now() - t); else if (performance.now() - t < 400) requestAnimationFrame(poll); };
requestAnimationFrame(poll);
};
new MutationObserver(look).observe(feed, { childList: true, subtree: true });
});
const t0 = await page.evaluate(() => performance.now());
await page.evaluate((ev) => window.__inject(ev), signed('PICTURE POST http://localhost:5296/slow.png?' + Date.now(), 0));
check('...sitting at the top', (await page.evaluate(() => window.scrollY)) < 120);
await page.waitForFunction(() => !!window.__firstSight, { timeout: 4000 }).catch(() => {});
await sleep(120); // a hair in: the animation has been started and not finished
const sight = await page.evaluate(() => window.__firstSight);
check('the post let itself in — no pill', !!sight && sight.img && !(await page.$('.feed-new-pill')), JSON.stringify(sight));
check('...having waited for the slow host', !!sight && sight.at - t0 >= 700, sight ? Math.round(sight.at - t0) + 'ms' : '');
const mid = await page.evaluate(() => {
const row = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /PICTURE POST/.test(n.innerText || ''));
return row ? { anims: row.getAnimations().length, h: row.getBoundingClientRect().height } : null;
});
await sleep(600);
const settled = await page.evaluate(() => {
const row = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /PICTURE POST/.test(n.innerText || ''));
return row ? { anims: row.getAnimations().length, h: row.getBoundingClientRect().height, overflow: row.style.overflow } : null;
});
check('...and it opened rather than appeared', !!mid && !!settled && mid.anims > 0 && mid.h < settled.h
&& settled.anims === 0 && settled.h > 0 && !settled.overflow, JSON.stringify({ mid, settled }));
// decoded before the row is inserted; the fresh <img> node reports it a
// task later, so the row's first frames are what is checked here
check('...and its picture was already decoded when the row appeared', !!sight && (sight.ready || sight.readyMs <= 60), JSON.stringify(sight));
// ---- coming back: the app was hidden for a while, then focused again. The
// catch-up re-asks the relays (force, past the throttle); the test answers.
const away = async (makeQueue) => {
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
});
await sleep(6500); // longer than the five seconds that count as a blink between apps
const reqsBefore = await page.evaluate(() => window.__feedReqs || 0);
const queue = makeQueue(); // signed now, after the wait: these are newer than everything on the page
await page.evaluate((q) => {
window.__catchup = q;
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
}, queue);
await sleep(6500); // the catch-up settles once every relay has answered (5s at most)
return { reqsBefore, reqsAfter: await page.evaluate(() => window.__feedReqs || 0) };
};
const label = (n) => n.innerText.split('\n').find((l) => /post number|NEW|ONE|POST|CATCH|TOP|AWAY/.test(l)) || n.innerText.slice(0, 40);
const anchor = () => page.evaluate((label) => {
const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => n.getBoundingClientRect().bottom > 120);
return r ? { text: eval(label)(r), top: Math.round(r.getBoundingClientRect().top), y: Math.round(window.scrollY) } : null;
}, label.toString());
const rowAt = (text) => page.evaluate((label, text) => {
const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => eval(label)(n) === text);
return r ? { text, top: Math.round(r.getBoundingClientRect().top), y: Math.round(window.scrollY) } : null;
}, label.toString(), text);
const topRow = () => page.evaluate(() => (document.querySelector('.notes-feed > .row') || {}).innerText || '');
console.log('\n[coming back after being away, a few new posts]');
await page.evaluate(() => window.scrollTo(0, 900));
await sleep(500);
const held = await anchor();
const r1 = await away(() => [signed('CATCH-UP 1', 0), signed('CATCH-UP 2', 1), signed('CATCH-UP 3', 2),
signed('an old post that was not in the cache', 5000), signed('another old one', 5100)]);
check('coming back re-asks the relays without being told', r1.reqsAfter > r1.reqsBefore, `${r1.reqsBefore} → ${r1.reqsAfter} kind-1 requests`);
const a1 = await rowAt(held.text);
const feedText = () => page.evaluate(() => document.querySelector('.notes-feed')?.innerText || '');
check('a few new posts go straight in', /CATCH-UP 1/.test(await feedText()));
check('...and the pill says how many are above you', /3 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || '')));
check('...and the post you were reading has not moved', !!held && !!a1 && a1.text === held.text && Math.abs(a1.top - held.top) <= 2,
JSON.stringify({ held, now: a1 }));
// they sort below everything cached: page down to them
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
await sleep(600);
check('...older posts that merely missed the cache went in quietly too', /not in the cache/.test(await feedText()));
await page.evaluate(() => window.scrollTo(0, 0));
await sleep(600);
check('...scrolling up reaches the newest post, and clears the pill', /CATCH-UP 1/.test(await topRow()) && !(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40));
console.log('\n[coming back while sitting at the top]');
const wasTop = await anchor();
await away(() => [signed('AT-THE-TOP A', 0), signed('AT-THE-TOP B', 1)]);
const a2 = await rowAt(wasTop.text);
check('the post you had at the top stays under your eyes', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y > 0,
JSON.stringify({ was: wasTop, now: a2 }));
check('...with the new ones above it, newest first, and a pill saying so', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || '')));
console.log('\n[coming back after a long time away]');
await page.evaluate(() => window.scrollTo(0, 900));
await sleep(400);
// all in the same second — a post per second back would reach past the
// top of the page after the thirteen seconds the two waits above took
await away(() => Array.from({ length: 25 }, (_, i) => signed('LONG AWAY ' + (i + 1), 0)));
const a3 = await anchor();
check('many new posts: the feed goes to the top', !!a3 && a3.y < 120, JSON.stringify(a3));
check('...at the newest post, with no pill', /LONG AWAY 1\b/.test(await topRow()) && !(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ the feed keeps up, without moving the page' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|