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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
|
// The media pager with a mouse (a laptop): a wheel notch is a step — down
// the next post, up the one before, sideways (or shift+wheel) along a post's
// pictures — a trackpad's run of small deltas one step per run, a spin of
// the wheel not five; ctrl+wheel (a trackpad's pinch) zooms the picture,
// the wheel pans it while zoomed and pages again at 1×; and arrows at the
// edges for the pointer, the sideways pair only where the post has more.
// Run: bun tools/pager-wheel-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 PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), e: png('240x180') };
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));
const m = /^\/(a1|a2|b|d|e)\d*\.png$/.exec(path);
if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } });
return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const U = (n) => server.url.origin + '/' + n + '.png';
const seeded = [
post('two pictures ' + U('a1') + ' ' + U('a2'), 100),
post('one picture ' + U('b'), 200),
post('only words here, nothing to look at', 300),
post('a last picture ' + U('d'), 400),
...Array.from({ length: 12 }, (_, i) => post('more ' + i + ' ' + U('e' + i), 500 + i * 60)), // a dozen more, one picture each, for a flight
];
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4'] /* a pointer that hovers: headless has none */ });
const page = await browser.newPage();
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.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; });
return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between';
});
const resting = async (name) => rested(name).then(async (r) => { await sleep(260); return r; }); // a breath after: the wheel's gate between steps
const rested = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 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.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 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);
try {
await page.setViewport({ width: 1280, height: 900 });
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: 'Picture 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));
const cdp = await page.target().createCDPSession();
// a trackpad's run: small deltas, page-side — a CDP mouseWheel is always a 120-unit notch to Chrome
const pad = (dy, n) => page.evaluate(async ([dy, n]) => { const sc = document.querySelector('.mp-scroll'); for (let i = 0; i < n; i++) { const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const ev = new WheelEvent('wheel', { deltaY: dy, deltaX: 0, deltaMode: 0, bubbles: true, cancelable: true, clientX: 640, clientY: 450 }); Object.defineProperty(ev, 'wheelDeltaY', { value: -Math.round(dy * 1.2) }); s.querySelector('img.mp-media').dispatchEvent(ev); await new Promise((r) => setTimeout(r, 16)); } }, [dy, n]);
const pos = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); return sc ? Math.round(sc.scrollTop) + '/' + sc.clientHeight + ' glide:' + !!sc._glide + ' snap:' + sc.style.scrollSnapType : 'none'; });
const wheel = (dy, { dx = 0, mods = 0 } = {}) => cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 640, y: 450, deltaX: dx, deltaY: dy, modifiers: mods });
const zoomOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /scale\(([\d.]+)\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? Number(m[1]) : 1; });
const shiftOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? [Number(m[1]), Number(m[2])] : [0, 0]; });
console.log('\n[a wheel notch is a step]');
await openPic('two pictures');
check('the picture opens in the pager', await resting('a1.png'), await curPic());
check('opening fills the media buffer without scrolling to the end', await waitFor(() => document.querySelectorAll('.mp-group').length === 15, null, 8000));
check('background loading keeps the first picture on screen', await curPic() === 'a1.png');
await page.mouse.move(640, 450);
await wheel(100);
check('a notch down: the next post\'s picture', await resting('b.png'), await curPic());
await wheel(100);
check('another: the next, past a post with none', await resting('d.png'), await curPic());
await wheel(-100);
check('a notch up: back one post', await resting('b.png'), await curPic());
await wheel(-100);
check('up again: the first post', await resting('a1.png'), await curPic());
await wheel(0, { dx: 100 });
check('sideways: the post\'s second picture', await resting('a2.png'), await curPic());
await wheel(0, { dx: 100 }); await sleep(300);
check('sideways at the post\'s last: stays', (await curPic()) === 'a2.png', await curPic());
await wheel(-100, { mods: 8 });
check('shift+wheel up: back to its first picture', await resting('a1.png'), await curPic());
await wheel(-100, { mods: 8 }); await sleep(300);
check('shift+wheel at the first: stays', (await curPic()) === 'a1.png', await curPic());
await wheel(100, { mods: 8 }); await resting('a2.png');
await sleep(300);
console.log('\n[a spin flies, and lands snapped]');
// posts with pictures, in order: a (two), b, d, e0 … e11 — a spin of five
// notches is 1 + 1 + 2 + 2 + 2 = 8 posts on from a: e5
for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); }
await sleep(250);
const mid = await pos();
check('mid-flight: between posts, gliding', /glide:true/.test(mid) && (await curPic()) === 'between', mid);
check('a spin of five notches flies eight posts on and lands snapped', await resting('e5.png'), await curPic() + ' ' + await pos());
check('the landing is arrived at: the sideways arrows know the post', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('has-right')));
for (let i = 0; i < 5; i++) { await wheel(-100); await sleep(10); }
check('a spin back flies back the same way', await resting('a2.png'), await curPic() + ' ' + await pos());
// a notch against a flight catches it on the post it is passing
for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); }
await sleep(200);
await wheel(-100);
await sleep(900);
const caught = await curPic();
check('a notch back mid-flight catches it, snapped on a post short of the target', caught !== 'between' && !['e5.png', 'a1.png', 'a2.png'].includes(caught), caught + ' ' + await pos());
await sleep(300);
await wheel(-100); await resting(caught === 'b.png' ? 'a2.png' : caught === 'd.png' ? 'b.png' : caught === 'e0.png' ? 'd.png' : 'e' + (Number(caught[1]) - 1) + '.png');
// back to b for what follows
while ((await curPic()) !== 'b.png') { await wheel((await curPic()) === 'a2.png' || (await curPic()) === 'a1.png' ? 100 : -100); await sleep(500); }
await sleep(300);
console.log('\n[a trackpad\'s run is one step]');
await sleep(300);
await pad(9, 14);
check('a run of small deltas steps once', await resting('d.png'), await curPic() + ' ' + await pos());
await sleep(400);
check('…and only once', (await curPic()) === 'd.png', await curPic());
await sleep(300);
await pad(-9, 14);
check('a run back steps back', await resting('b.png'), await curPic());
console.log('\n[ctrl+wheel zooms, the wheel pans, 1× pages]');
await sleep(400);
await wheel(-100, { mods: 2 }); await sleep(100);
const z1 = await zoomOf();
check('ctrl + a notch up zooms the picture in', z1 > 1.2, 'scale ' + z1);
await wheel(-100, { mods: 2 }); await sleep(100);
check('…and more', (await zoomOf()) > z1, 'scale ' + await zoomOf());
const before = await shiftOf();
await wheel(100); await sleep(300);
check('a plain notch while zoomed pans, it does not page', (await curPic()) === 'b.png' && JSON.stringify(await shiftOf()) !== JSON.stringify(before), await curPic() + ' ' + JSON.stringify(before) + ' → ' + JSON.stringify(await shiftOf()));
for (let i = 0; i < 6; i++) { await wheel(100, { mods: 2 }); await sleep(30); }
await sleep(100);
check('ctrl + notches down zoom back out to 1×', (await zoomOf()) === 1, 'scale ' + await zoomOf());
await sleep(300);
await wheel(100);
check('at 1× the wheel pages again', await resting('d.png'), await curPic());
console.log('\n[arrows for the pointer]');
const shown = (sel) => page.evaluate((s) => { const b = document.querySelector(s); return !!b && getComputedStyle(b).display !== 'none' && b.getBoundingClientRect().width > 0; }, sel);
check('up and down arrows are on the pager', await shown('.mp-arrow.mp-up') && await shown('.mp-arrow.mp-down'));
check('a post with one picture shows no sideways arrows', !(await shown('.mp-arrow.mp-ar-left')) && !(await shown('.mp-arrow.mp-ar-right')));
await page.click('.mp-arrow.mp-up');
check('the up arrow: back one post', await resting('b.png'), await curPic());
await page.click('.mp-arrow.mp-up');
check('up again: the first post, on the picture it was left at', await resting('a2.png'), await curPic());
await sleep(200);
check('on its last picture: a left arrow, no right', await shown('.mp-arrow.mp-ar-left') && !(await shown('.mp-arrow.mp-ar-right')));
await page.click('.mp-arrow.mp-ar-left');
check('the left arrow: its first picture', await resting('a1.png'), await curPic());
await sleep(200);
check('on its first: a right arrow, no left', await shown('.mp-arrow.mp-ar-right') && !(await shown('.mp-arrow.mp-ar-left')));
await page.click('.mp-arrow.mp-ar-right');
check('the right arrow: its second picture', await resting('a2.png'), await curPic());
check('an arrow\'s click is not a tap on the picture (no details, still open)', await page.evaluate(() => { const p = document.querySelector('.media-pager'); return !!p && !p.classList.contains('show'); }));
await page.click('.mp-arrow.mp-down');
check('the down arrow: the next post', await resting('b.png'), await curPic());
await page.keyboard.press('Escape');
check('Escape still closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
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✅ pager: wheel steps, ctrl+wheel zooms, arrows for the pointer' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|