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
|
// The pager keeps its post when the screen changes size: the page going
// full screen for it (a taller window, the address bar gone), or a turn of
// the phone. Posts are a screen tall, so a pixel scroll position deep in
// the pager lands in another post after a resize — Adam's full-screen
// button on a clip 23 posts down showed him another post's picture. The
// resize can come before the observer has even named the first slide.
// Run: bun tools/pager-resize-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[the screen grows right as the pager opens]');
// the feed draws a window of rows: scroll down until the post is drawn
for (let i = 0; i < 40; i++) {
if (await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); if (!r) return false; r.scrollIntoView({ block: 'center' }); return true; }, 'more 8')) break;
await page.evaluate(() => window.scrollBy(0, 1500)); await sleep(300);
}
await sleep(500);
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 }; }, 'more 8');
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager'), null, 4000);
// the window grows (full screen) before the pager's observer has named a slide
await page.setViewport({ width: 1280, height: 1040 });
check('the pager rests on the picture tapped, eleven posts down', await resting('e8.png'), await curPic() + ' ' + await pos());
await sleep(600);
check('…and stays there', (await curPic()) === 'e8.png', await curPic());
console.log('\n[a later resize]');
await page.keyboard.press('ArrowDown'); await resting('e9.png');
await page.setViewport({ width: 1280, height: 780 });
check('the window shrinks: still on the post the reader moved to', await resting('e9.png'), await curPic() + ' ' + await pos());
await page.setViewport({ width: 1280, height: 1100 });
check('…and grows again: still there', await resting('e9.png'), await curPic() + ' ' + await pos());
await page.keyboard.press('ArrowUp'); await resting('e8.png');
await page.setViewport({ width: 900, height: 1200 });
check('a different shape: still on the post', await resting('e8.png'), await curPic() + ' ' + await pos());
console.log('\n[a wide window: the words and counts keep to the middle]');
await page.setViewport({ width: 1920, height: 600 });
await resting('e8.png');
await page.keyboard.press('ArrowDown'); await resting('e9.png'); // moved on from the start: a tap shows the details (on the start it closes)
await page.mouse.click(960, 300); await sleep(400);
const geo = 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; }); const r = (q) => { const e = s.querySelector(q); const b = e.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.right)]; }; return { show: document.querySelector('.media-pager').classList.contains('show'), left: r('.mp-left'), counts: r('.mp-counts'), meta: r('.mp-meta') }; });
check('the details are up', geo.show);
check('the words start near the middle column, not the screen\'s edge', geo.left[0] >= 560 && geo.left[0] <= 600, JSON.stringify(geo));
check('the counts sit at the column\'s edge, not the screen\'s corner', geo.counts[1] >= 1320 && geo.counts[1] <= 1360, JSON.stringify(geo));
check('the gradient still spans the screen', geo.meta[0] === 0 && geo.meta[1] === 1920, JSON.stringify(geo));
await page.setViewport({ width: 390, height: 844 }); await sleep(300);
const narrow = 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; }); const b = s.querySelector('.mp-left').getBoundingClientRect(); return [Math.round(b.left), Math.round(s.querySelector('.mp-counts').getBoundingClientRect().right)]; });
check('a phone keeps the full width', narrow[0] <= 16 && narrow[1] >= 370, JSON.stringify(narrow));
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✅ the pager keeps its post through a resize' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|