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
|
// A tap on a clip in the pager is a tap even if the finger moves a little:
// the browser takes a touch that moves a few pixels for a scroll (no click,
// and a quick one could fling the pager on). A touch that travels less than
// 30px pauses (or plays) the clip and leaves the pager where it was; only a
// longer one pages.
// Run: bun tools/pager-tap-slop-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);
screen.orientation.lock = () => Promise.resolve(); screen.orientation.unlock = () => {};
});
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 st = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const v = s && s.querySelector('video.mp-media'); return { on: s ? s.getAttribute('data-k').split('/').pop() : 'between', paused: v ? v.paused : null, show: !!document.querySelector('.media-pager.show') }; });
// a touch from (x, y) that moves dy (and dx) over a few steps, quickly
const drag = async (x, y, dx, dy, steps = 3) => {
await page.touchscreen.touchStart(x, y);
for (let i = 1; i <= steps; i++) { await page.touchscreen.touchMove(x + dx * i / steps, y + dy * i / steps); await sleep(16); }
await page.touchscreen.touchEnd();
await sleep(900);
};
try {
await page.setViewport({ width: 390, height: 844, 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: '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());
await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a tall one')).querySelector('video.note-video').scrollIntoView({ block: 'center' }));
await sleep(1200);
const at = await page.evaluate(() => { const b = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a tall one')).querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
await page.touchscreen.tap(at.x, at.y);
check('the pager opens on the clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 0.5; }, null, 8000);
await sleep(1000); // past the opening tap's own click
let s = await st();
check('it plays', s.on === 'tall.webm' && s.paused === false, JSON.stringify(s));
console.log('\n[a tap whose finger drifts]');
await drag(195, 420, 0, -18);
s = await st();
check('18px up: a tap — the clip pauses', s.paused === true, JSON.stringify(s));
check('…on the slide it was on', s.on === 'tall.webm', JSON.stringify(s));
await drag(195, 420, 6, 14);
s = await st();
check('14px down and a little across: a tap — it plays on, still there', s.paused === false && s.on === 'tall.webm', JSON.stringify(s));
await drag(195, 420, 0, -20, 1);
s = await st();
check('a quick 20px flick: still a tap — paused, not paged', s.paused === true && s.on === 'tall.webm', JSON.stringify(s));
await drag(195, 420, 0, 0, 1);
s = await st();
check('a still tap plays it again', s.paused === false && s.on === 'tall.webm', JSON.stringify(s));
console.log('\n[a swipe is still a swipe]');
await drag(195, 650, 0, -320, 8);
s = await st();
check('a 320px swipe up: the next post', s.on !== 'tall.webm' && s.on !== 'between', JSON.stringify(s));
const was = s.on;
await drag(195, 500, 0, 300, 8);
s = await st();
check('a 300px swipe down: back a post', s.on === 'tall.webm', JSON.stringify(s));
// a slow, short drag (past a tap, short of a fifth of the screen): back where it was
await page.touchscreen.touchStart(195, 500);
for (let i = 1; i <= 10; i++) { await page.touchscreen.touchMove(195, 500 - 7 * i); await sleep(60); }
await sleep(200); await page.touchscreen.touchEnd(); await sleep(900);
s = await st();
check('a slow 70px drag springs back', s.on === 'tall.webm', JSON.stringify(s));
await drag(195, 500, 0, -80, 2);
s = await st();
check('a quick 80px flick up: the next post', s.on === was, JSON.stringify(s));
await drag(195, 500, 0, 300, 8);
await drag(195, 500, 0, -40, 2);
s = await st();
check('a quick 40px flick up: the next post too', s.on === was, JSON.stringify(s));
await drag(195, 500, 0, 300, 8);
await page.touchscreen.touchStart(195, 500);
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(195, 500 - 9 * i); await sleep(30); }
await page.touchscreen.touchEnd(); await sleep(900);
s = await st();
check('a steady 108px drag up (an eighth of the screen): the next post', s.on === was, JSON.stringify(s));
console.log('\n[snappy: let go, and it lands at once]');
await drag(195, 500, 0, 300, 8);
await page.touchscreen.touchStart(195, 650);
for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(195, 650 - 40 * i); await sleep(16); }
// from the finger lifting to the next slide resting square on the screen
await page.evaluate(() => { window.__landed = new Promise((res) => { const t0 = performance.now(); const look = () => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 1 && Math.abs(r.left) < 1; }); if (s && !s.getAttribute('data-k').endsWith('/tall.webm')) res(Math.round(performance.now() - t0)); else if (performance.now() - t0 > 2000) res(-1); else requestAnimationFrame(look); }; requestAnimationFrame(look); }); });
await page.touchscreen.touchEnd();
const ms = await page.evaluate(() => window.__landed);
check('a swipe lands on the next post within 250ms', ms > 0 && ms <= 250, ms + 'ms');
console.log('\n[no overshoot: a post not quite a screen tall, as 100dvh can be on a phone]');
await page.addStyleTag({ content: '.mp-group, .mp-tail { height: calc(100dvh + 12px) !important; }' });
await page.evaluate(() => { document.querySelector('.mp-scroll').scrollTop = 0; });
await sleep(600);
// every frame's scroll, from the finger lifting until well after it lands
const glide = async () => {
await page.touchscreen.touchStart(195, 650);
for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(195, 650 - 40 * i); await sleep(16); }
await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); window.__trace = []; const t0 = performance.now(); const rec = () => { window.__trace.push(sc.scrollTop); if (performance.now() - t0 < 900) requestAnimationFrame(rec); }; requestAnimationFrame(rec); });
await page.touchscreen.touchEnd();
await sleep(1100);
return page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const tops = [...sc.querySelectorAll('.mp-group, .mp-tail')].map((g) => g.getBoundingClientRect().top); return { trace: window.__trace, off: Math.min(...tops.map(Math.abs)) }; });
};
for (let n = 1; n <= 2; n++) {
const g = await glide();
const end = g.trace[g.trace.length - 1];
const past = Math.max(...g.trace.map((x) => x - end));
const back = g.trace.some((x, i) => i && x < g.trace[i - 1] - 0.5);
check('swipe ' + n + ': straight on to the post, never past it and back', past <= 1 && !back, g.trace[0].toFixed(1) + '→' + end.toFixed(1) + ', past by ' + past.toFixed(1) + 'px' + (back ? ', moved back' : ''));
check('…resting square on it', g.off < 1, g.off.toFixed(2) + 'px off');
}
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a little movement is still a tap' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|