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
|
// The end of the pager: swiping past the last clip (when the next isn't in
// yet) lands on a "fetching the next video" screen, not a nudge that goes
// nowhere; the clip left behind stops; the next clip, when it arrives, is
// moved onto and plays by itself. With nothing older to be had, the end says
// that's everything. Escape from there lands on the last clip's post.
// Run: bun tools/pager-tail-test.js (SHOT=path.png saves the loading screen)
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
import { existsSync } from 'node:fs';
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 CLIP = '/tmp/coinos-tail-clip.webm';
if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=180x320:rate=10', '-t', '20', '-c:v', 'libvpx', '-b:v', '100k', CLIP]);
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 (/^\/c\d\.webm$/.test(path)) return new Response(Bun.file(CLIP), { headers: { 'content-type': 'video/webm' } });
if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const U = (n) => server.url.origin + '/' + n + '.webm';
// what the phone has: two clips among words; what only the relays have (slowly): two older clips
const cached = [
post('clip zero ' + U('c0'), 100), post('words between ' + 'lorem ipsum. '.repeat(6), 200), post('clip one ' + U('c1'), 300),
];
const older = [post('clip two ' + U('c2'), 5000), post('more words ' + 'lorem ipsum. '.repeat(6), 5100), post('clip three ' + U('c3'), 5200)];
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage();
// The relays: the newest posts at once; anything asked for by age (until) comes 3 s later
await page.evaluateOnNewDocument(([fresh, old]) => {
const all = [...fresh, ...old];
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('#'));
window.__olderAsks = 0;
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, ms = 5) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), ms);
if (m[0] === 'REQ') {
const filters = m.slice(2).filter((f) => !f.cache);
const slow = filters.some((f) => f.until);
if (slow) window.__olderAsks++;
const pool = slow ? all : fresh;
const ms = slow ? 3000 : 5;
for (const f of filters) {
const hits = pool.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], ms);
}
reply(['EOSE', m[1]], ms + 5);
} 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;
}, [cached, 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) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
// what the pager rests on: a clip's name, or 'tail'
const AT = () => { 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 ? 'between' : s.classList.contains('mp-tail') ? 'tail' : s.getAttribute('data-k').split('/').pop().replace('.webm', ''); };
const at = () => page.evaluate(AT);
const resting = (name, ms = 5000) => waitFor(([f, n]) => eval(f)() === n, ['(' + AT + ')', name], ms);
const playing = (name) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => (x.getAttribute('data-k') || '').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v && !v.paused && v.currentTime > 0; }, name, 6000);
const tail = () => page.evaluate(() => { const t = document.querySelector('.mp-tail'); return t ? { text: t.textContent.trim(), done: t.classList.contains('done'), last: t === t.parentElement.lastElementChild } : 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: 'Tail Poster', 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());
await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
await sleep(1500);
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
await sleep(400);
await page.mouse.click(r.x, r.y);
check('the pager opens on the first clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000) && await resting('c0'), await at());
check('a loading screen waits at the end, after the clips', (await tail())?.last, JSON.stringify(await tail()));
console.log('\n[past the last clip in: the loading screen, not a nudge]');
await page.keyboard.press('ArrowDown');
check('down: the second clip', await resting('c1'), await at());
await playing('c1');
await page.keyboard.press('ArrowDown');
check('down again: the loading screen', await resting('tail', 2000), await at());
const tl = await tail();
check('…saying the next video is being fetched', /fetching the next video/i.test(tl.text) && !tl.done, JSON.stringify(tl));
check('…the clip left behind stops', await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => (x.getAttribute('data-k') || '').endsWith('/c1.webm')); return s.querySelector('video.mp-media').paused; }));
check('…and older posts are being asked for', await page.evaluate(() => window.__olderAsks > 0));
if (process.env.SHOT) { await sleep(400); await page.screenshot({ path: process.env.SHOT }); }
console.log('\n[the next clip arrives: moved onto, playing]');
check('the pager moves onto it by itself', await resting('c2', 9000), await at());
check('…and it plays', await playing('c2'));
check('the loading screen is still last', (await tail())?.last);
console.log('\n[nothing older: that\'s everything]');
await page.keyboard.press('ArrowDown');
check('down: the clip that came with it', await resting('c3'), await at());
await page.keyboard.press('ArrowDown');
check('down: the end again', await resting('tail', 2000), await at());
check('…which says that\'s everything, once the relays have nothing older', await waitFor(() => { const t = document.querySelector('.mp-tail'); return t && t.classList.contains('done') && /everything/i.test(t.textContent); }, null, 20000), JSON.stringify(await tail()));
check('…and stays put', (await at()) === 'tail');
console.log('\n[Escape from the end]');
await page.keyboard.press('Escape');
check('the pager closes', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
await sleep(1500);
const top = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('clip three')); return r ? Math.round(r.getBoundingClientRect().top) : null; });
check('…onto the last clip\'s post', top !== null && top >= -10 && top < 120, 'row top ' + top);
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ the end of the pager fetches, shows it, and moves on when the next arrives' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|