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
|
// Swiping through the feed's pictures (or clips) in the pager is scrolling
// the feed: with three slides or fewer left past the one on screen, the
// feed pages on, and the pager grows — past a run of posts with nothing to
// show, too — so the reader never meets the end while there is more.
// Run: bun tools/feed-pager-ahead-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 png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
const PIC = png('320x240');
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 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));
if (/^\/p\d+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } });
return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const U = (n) => server.url.origin + '/p' + n + '.png';
// 12 picture posts, 15 with only words, then 14 more pictures: 26 in all
const seeded = [];
let ago = 100, pic = 0;
for (let i = 0; i < 12; i++) seeded.push(post('picture number ' + pic + ' ' + U(pic++), ago += 60));
for (let i = 0; i < 15; i++) seeded.push(post('just words ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(3), ago += 60));
for (let i = 0; i < 14; i++) seeded.push(post('picture number ' + pic + ' ' + U(pic++), ago += 60));
const TOTAL = pic;
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
// The relays, in the page: every one holds all the posts (the feed's own
// cache keeps only the newest 30, so the rest have to come from here) and
// answers a REQ by its kinds, authors, since, until and limit.
await page.evaluateOnNewDocument((evs) => {
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.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#'));
class FakeWS {
constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); }
send(raw) {
const m = JSON.parse(raw);
const reply = (x) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), 5);
if (m[0] === 'REQ') {
for (const f of m.slice(2)) {
if (f.cache) continue;
const hits = evs.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at);
for (const e of f.limit ? hits.slice(0, f.limit) : hits) reply(['EVENT', m[1], e]);
}
reply(['EOSE', m[1]]);
} else if (m[0] === 'EVENT') reply(['OK', m[1].id, true, '']);
else if (m[0] === 'COUNT') reply(['COUNT', m[1], { count: 0 }]);
}
close() { this.readyState = 3; this.onclose && this.onclose({ code: 1000 }); }
addEventListener(t, fn) { this['on' + t] = fn; }
removeEventListener() {}
}
FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3;
window.WebSocket = FakeWS;
}, seeded);
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; };
// the slide the pager rests on, by its picture
const curPic = () => page.evaluate(() => {
const sc = document.querySelector('.mp-scroll'); if (!sc) return null;
const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4);
return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
});
const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000);
const openPic = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager'), null, 4000);
return at.y0;
};
const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); };
const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word);
const slides = () => page.evaluate(() => document.querySelector('.mp-scroll')?.children.length || 0);
const curIdx = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); if (!sc) return -1; return [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); });
const curName = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = sc && [...sc.children].find((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return s ? s.getAttribute('data-k').split('/').pop() : null; });
try {
await page.setViewport({ width: 390, height: 844 });
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: 'Pager Poster', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
}, [base, AUTHOR, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000));
console.log('\n[the pager starts with the feed\'s first page]');
await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture number 0 ')); r.scrollIntoView({ block: 'center' }); });
await sleep(400);
const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture number 0 ')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000));
const first = await slides();
check('it holds the first page\'s pictures, not all of them', first >= 1 && first < TOTAL, first + ' of ' + TOTAL);
console.log('\n[nearing the end pages the feed on]');
let grew = false, maxSlides = first;
for (let step = 0; step < TOTAL + 4; step++) {
const n = await slides(), i = await curIdx();
maxSlides = Math.max(maxSlides, n);
if (i >= 0 && n - 1 - i <= 3) {
// give the look-ahead a moment: it should get past three ahead again (or the feed's end)
await waitFor((k) => { const sc = document.querySelector('.mp-scroll'); const j = [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4); return sc.children.length - 1 - j > 3 || sc.children.length >= k; }, TOTAL, 6000);
}
const before = await curIdx();
await page.keyboard.press('ArrowDown');
await waitFor((b) => { const sc = document.querySelector('.mp-scroll'); return [...sc.children].findIndex((x) => Math.abs(x.offsetTop - sc.scrollTop) < 4) > b; }, before, 2000);
}
grew = (await slides()) > first;
check('the pager grew as the end came near', grew, first + ' → ' + (await slides()));
check('every picture in the feed became reachable', (await slides()) === TOTAL, (await slides()) + ' of ' + TOTAL);
check('…past fifteen posts with none', (await curName()) === 'p' + (TOTAL - 1) + '.png', await curName());
check('…in the feed\'s order', await page.evaluate((t) => [...document.querySelector('.mp-scroll').children].every((s, i) => s.getAttribute('data-k').endsWith('/p' + i + '.png')), TOTAL));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ the pager pages the feed on ahead of the reader' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|