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
|
// The feed pages to the reader's appetite. Reading at an ordinary pace it
// shows ten more posts at a time, and asks the relays for older ones while
// a page or two is still in hand — never once the reader has run out. A
// reader flying through (five screens in ten seconds, by their own hand)
// gets bigger pages, from further above the foot of the feed; standing
// still a while brings the ordinary page back. A page held still under an
// insert above is not travel.
// Run: bun tools/feed-appetite-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 SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
const post = (i) => finalizeEvent({ kind: 1, created_at: now - 60 * i, tags: [], content: 'post number ' + i + ' of the long timeline' }, SK);
const cached = Array.from({ length: 60 }, (_, i) => post(i)); // in the feed cache
const older = Array.from({ length: 300 }, (_, i) => post(60 + i)); // on the relays, for the asks
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();
// The relays, answered in the page: an ask for older posts (a filter with
// `until` for the author) gets the thirty newest at or before it, after a
// moment, then EOSE. Every ask is logged with the feed's state at that moment.
await page.evaluateOnNewDocument((fx) => {
window.__asks = []; window.__steps = [];
window.__snap = () => { const f = document.querySelector('.notes-feed'); return { t: Date.now(), shown: +(f?.dataset.shown || 0), held: +(f?.dataset.held || 0), remaining: Math.round(document.documentElement.scrollHeight - window.scrollY - window.innerHeight) }; };
const Real = window.WebSocket;
const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); };
const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); };
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') {
const f = m.slice(2).find((f) => f.until && (f.kinds || []).includes(1) && (f.authors || []).includes(fx.author));
if (f) {
if (!window.__asks.some((a) => a.until === f.until)) window.__asks.push({ ...window.__snap(), until: f.until, limit: f.limit || 0 });
const evs = fx.older.filter((e) => e.created_at <= f.until).slice(0, f.limit || 30);
setTimeout(() => { for (const ev of evs) send1(ws, m[1], ev); eose(ws, m[1]); }, 300);
return;
}
}
} catch {}
return send(data);
};
return ws;
};
window.WebSocket.prototype = Real.prototype;
Object.assign(window.WebSocket, Real);
}, { author: AUTHOR, older });
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, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; };
const snap = () => page.evaluate(() => window.__snap());
const steps = () => page.evaluate(() => window.__steps);
const asks = () => page.evaluate(() => window.__asks);
// a scroll by the reader's own hand: an input stamp, then the page moves
const handScroll = (dy) => page.evaluate((dy) => { window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); window.scrollBy({ top: dy, behavior: 'instant' }); }, dy);
const H = 500;
try {
await page.setViewport({ width: 390, height: H, 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, 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: 'Long Timeline', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
}, [base, AUTHOR, cached]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
check('the cached posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 5, null, 20000));
// every change of the page count, with the feed's state at that moment
await page.evaluate(() => {
const f = document.querySelector('.notes-feed');
let last = +f.dataset.shown;
new MutationObserver(() => { const n = +(document.querySelector('.notes-feed')?.dataset.shown || 0); if (n !== last) { window.__steps.push({ ...window.__snap(), from: last }); last = n; } })
.observe(document.querySelector('#app') || document.body, { attributes: true, subtree: true, attributeFilter: ['data-shown'] });
});
const s0 = await snap();
check('it opens on a page of ten, with the cached posts in hand', s0.shown === 10 && s0.held >= 30, JSON.stringify(s0)); // (the feed cache keeps thirty)
console.log('\n[reading at an ordinary pace]');
// a few posts every so often, no hand on it (the browser's own scroll: the
// trail counts only travel within a moment of an input)
for (let i = 0; i < 60; i++) {
await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' }));
await sleep(600);
const s = await snap();
if (s.shown >= 60) break;
}
await waitFor(() => +(document.querySelector('.notes-feed')?.dataset.shown || 0) >= 60, null, 8000);
const slow = await steps();
check('pages of ten', slow.length >= 3 && slow.every((s) => s.shown - s.from <= 10), slow.map((s) => s.from + '→' + s.shown).join(' '));
const a = await asks();
check('the relays were asked for older posts while posts were still in hand', a.length >= 1 && a[0].shown < a[0].held, JSON.stringify(a[0]));
const at60 = slow.find((s) => s.shown >= 60);
check('…so reaching the end of what was held found more already there', !!at60 && at60.held > at60.shown, JSON.stringify(at60));
check('the ordinary pace never asked for more than a page at a time', slow.every((s) => s.shown - s.from <= 10));
console.log('\n[flying through]');
await sleep(10_500); // the trail forgets the slow spell
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await sleep(600);
const before = await steps();
const sB = await snap();
// eleven screens in half a second, by hand; a breath; eleven more
for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); }
await sleep(1500);
for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); }
await waitFor((n) => window.__steps.length > n && window.__steps.slice(n).some((s) => s.shown - s.from >= 30), before.length, 6000);
await sleep(1500);
const fast = (await steps()).slice(before.length);
const big = fast.find((s) => s.shown - s.from >= 20);
check('a bigger page at once', !!big, fast.map((s) => s.from + '→' + s.shown).join(' ') || 'no page');
// (a page begun mid-flight is sized to the screens flown so far; the ones after are the full three)
check('…growing to three pages\' worth', fast.some((s) => s.shown - s.from >= 30), fast.map((s) => s.shown - s.from).join(' '));
check('…asked for from further above the foot than the ordinary lead', !!big && big.remaining > Math.max(3000, 4 * H), big ? big.remaining + ' px left below' : '');
const a2 = (await asks()).slice(a.length);
check('…and the relays asked for more per round trip', a2.some((x) => x.limit >= 60), a2.map((x) => x.limit).join(' ') || 'no ask');
console.log('\n[standing still again]');
await sleep(10_500);
const before3 = await steps();
for (let i = 0; i < 30; i++) {
await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' }));
await sleep(600);
if ((await steps()).length > before3.length) break;
}
await sleep(500);
const calm = (await steps()).slice(before3.length);
check('the ordinary page is back', calm.length >= 1 && calm.every((s) => s.shown - s.from <= 10), calm.map((s) => s.from + '→' + s.shown).join(' ') || 'no page');
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 pages to the reader\'s appetite' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|