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
|
// A clip tapped in the feed opens in the media pager, over the feed's clips
// (every shape, a post's own first), carrying on from where it was. The
// pager takes the whole screen and a wide clip turns the phone on its side —
// for that clip only. Each clip has a seek bar to drag; a tap pauses (and
// shows the details), another plays on. Leaving full screen leaves the pager.
// Run: bun tools/feed-video-pager-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';
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 = (name, size) => {
const f = '/tmp/coinos-vpager-' + name + '.webm';
if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
return f;
};
const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
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;
const m = /^\/(wide|tall|last)\.webm$/.exec(path);
if (m) return new Response(Bun.file(CLIPS[m[1]]), { 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';
const seeded = [
post('a wide one ' + U('wide'), 100),
post('a tall one ' + U('tall'), 200),
post('only words here, nothing to watch', 300),
post('the last clip ' + U('last'), 400),
...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60)),
];
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();
await page.evaluateOnNewDocument(() => {
const Real = window.WebSocket;
window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
// a desktop can't turn: note what the page asks of the screen instead
window.__orient = [];
try {
screen.orientation.lock = (o) => { window.__orient.push('lock:' + o); return Promise.resolve(); };
screen.orientation.unlock = () => { window.__orient.push('unlock'); };
} catch {}
});
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 CUR = () => { const sc = document.querySelector('.mp-scroll'); return sc && [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); };
const curClip = () => page.evaluate((cur) => { const s = eval(cur)(); return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : null; }, '(' + CUR + ')');
const resting = (name) => waitFor(([cur, n]) => { const s = eval(cur)(); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, ['(' + CUR + ')', name], 5000);
const curVideo = () => page.evaluate((cur) => { const s = eval(cur)(); const v = s && s.querySelector('video.mp-media'); return v ? { t: v.currentTime, d: v.duration, paused: v.paused, src: !!v.getAttribute('src') } : null; }, '(' + CUR + ')');
const pagerState = () => page.evaluate(() => ({ open: !!document.querySelector('.media-pager.videos'), fs: document.fullscreenElement === document.documentElement, show: !!document.querySelector('.media-pager.show'), orient: window.__orient.slice() }));
const feedClip = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const v = r && r.querySelector('video.note-video'); return v ? { t: v.currentTime, paused: v.paused } : null; }, word);
const openClip = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').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 b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
const tapPager = async () => { await page.mouse.click(195, 300); await sleep(400); };
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);
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: 'Clip 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 clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
console.log('\n[a tap opens the pager, full screen, where the clip was]');
await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')).querySelector('video.note-video').scrollIntoView({ block: 'center' }));
for (let i = 0; i < 40; i++) { const c = await feedClip('a wide one'); if (c && c.t > 2) break; await sleep(250); }
const before = (await feedClip('a wide one'))?.t || 0;
check('the wide clip plays in the feed first', before > 1.5, 't=' + before.toFixed(2));
await openClip('a wide one');
let s = await pagerState();
check('the tap opens the video pager', s.open);
check('…on the clip tapped', await resting('wide.webm'), await curClip());
check('…full screen', await waitFor(() => document.fullscreenElement === document.documentElement, null, 3000));
check('…turned on its side, for a wide clip', await waitFor(() => window.__orient.includes('lock:landscape'), null, 3000), JSON.stringify((await pagerState()).orient));
check('the clip in the feed holds still underneath', (await feedClip('a wide one'))?.paused);
await waitFor((cur) => { const v = eval(cur)()?.querySelector('video.mp-media'); return v && !v.paused && v.currentTime > 0; }, '(' + CUR + ')', 6000);
let v = await curVideo();
check('the clip plays on in the pager', v && !v.paused, JSON.stringify(v));
check('…from where it was in the feed', v && v.t >= before - 0.3, `feed ${before.toFixed(2)}, pager ${v && v.t.toFixed(2)}`);
console.log('\n[the seek bar]');
check('the seek bar is up once the clip knows its length', await waitFor((cur) => !!eval(cur)()?.querySelector('.mp-seek.ready'), '(' + CUR + ')', 4000));
// clear of the phone's gesture strip along the bottom, and of the side edges (back gesture)
const geo = await page.evaluate((cur) => { const r = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { bottom: Math.round(innerHeight - r.bottom), left: Math.round(r.left), right: Math.round(innerWidth - r.right) }; }, '(' + CUR + ')');
check('the seek bar sits clear of the bottom edge', geo.bottom >= 32, JSON.stringify(geo));
check('…and in from the sides', geo.left >= 16 && geo.right >= 16, JSON.stringify(geo));
if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.media-pager').classList.add('show')); await sleep(300); await page.screenshot({ path: process.env.SHOT }); await page.evaluate(() => document.querySelector('.media-pager').classList.remove('show')); }
const bar = await page.evaluate((cur) => { const r = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: r.x, y: r.y + r.height - 4, w: r.width }; }, '(' + CUR + ')');
await page.mouse.move(bar.x + bar.w * 0.1, bar.y);
await page.mouse.down();
await page.mouse.move(bar.x + bar.w * 0.4, bar.y, { steps: 4 });
check('dragging shows the time', await page.evaluate((cur) => { const b = eval(cur)().querySelector('.mp-seek'); return b.classList.contains('drag') && /\d:\d\d \/ 0:30/.test(b.textContent); }, '(' + CUR + ')'));
await page.mouse.move(bar.x + bar.w * 0.7, bar.y, { steps: 4 });
await page.mouse.up();
await sleep(500);
v = await curVideo();
check('the drag scrubs the clip', v && Math.abs(v.t - 0.7 * v.d) < 1.5, `t=${v && v.t.toFixed(2)} of ${v && v.d}`);
check('…and it plays on after', v && !v.paused);
check('…without paging', await resting('wide.webm'), await curClip());
check('…or bringing up the details', !(await pagerState()).show);
console.log('\n[a tap pauses and shows the details; another plays on]');
await tapPager();
v = await curVideo(); s = await pagerState();
check('a tap pauses', v && v.paused, JSON.stringify(v));
check('…and shows the details', s.show);
check('…with the author and the words', await page.evaluate((cur) => { const x = eval(cur)(); return /Clip Poster/.test(x.textContent) && /a wide one/.test(x.textContent); }, '(' + CUR + ')'));
check('…clear of the seek bar', await page.evaluate((cur) => { const x = eval(cur)(); const bar = x.querySelector('.mp-seek-track').getBoundingClientRect(); return [...x.querySelectorAll('.mp-left > *, .mp-counts > *')].every((n) => n.getBoundingClientRect().bottom <= bar.top - 4); }, '(' + CUR + ')'));
check('…still in the pager', s.open && s.fs);
await tapPager();
v = await curVideo(); s = await pagerState();
check('another tap plays on', v && !v.paused, JSON.stringify(v));
check('…and hides the details', !s.show);
console.log('\n[the next clip: a swipe away, the screen free to turn]');
await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); sc.scrollTo({ top: sc.clientHeight }); }); // what a swipe up does
check('a swipe lands on the next clip (a tall one)', await resting('tall.webm'), await curClip());
check('…and lets the screen follow the phone', await waitFor(() => window.__orient.lastIndexOf('unlock') > window.__orient.indexOf('lock:landscape'), null, 3000), JSON.stringify((await pagerState()).orient));
check('…playing', await waitFor((cur) => { const v = eval(cur)()?.querySelector('video.mp-media'); return v && !v.paused; }, '(' + CUR + ')', 6000));
await page.keyboard.press('ArrowDown');
check('down: the last clip, past a post with none', await resting('last.webm'), await curClip());
await page.keyboard.press('ArrowUp');
check('up: back to the tall one', await resting('tall.webm'), await curClip());
console.log('\n[Escape: out of the pager and full screen, onto the post]');
await page.keyboard.press('Escape');
check('the pager closes', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
check('…and full screen with it', await waitFor(() => !document.fullscreenElement, null, 3000));
await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r && r.getBoundingClientRect().top < 120 && r.getBoundingClientRect().top > -10; }, 'a tall one', 3000);
check('…the feed on the tall clip\'s post', (await rowTop('a tall one')) !== null && (await rowTop('a tall one')) < 120 && (await rowTop('a tall one')) > -10, 'row top ' + (await rowTop('a tall one')));
console.log('\n[leaving full screen another way (Back on a phone) leaves the pager]');
await openClip('a tall one');
check('the pager is open, full screen', await waitFor(() => !!document.querySelector('.media-pager.videos') && document.fullscreenElement === document.documentElement, null, 3000));
check('a tall clip leaves the screen as it is', !(await pagerState()).orient.slice(1).includes('lock:landscape'), JSON.stringify((await pagerState()).orient));
await page.evaluate(() => document.exitFullscreen());
check('out of full screen, out of the pager', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ feed clips page full screen, with a seek bar' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|