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
|
// On a phone, a fast flick up or down in the media pager flies past several
// posts and lands snapped on one, the way a spin of the wheel does; a
// gentler swipe is one post, as before; a fling at the end lands on the
// last post and goes no further (no going round to the first); a tap
// mid-flight stops it on the post most on screen, and a swipe back from
// there goes to the one before.
// Run: bun tools/pager-swipe-fly-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 N = 12, PIC = png('360x360');
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
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));
if (/^\/p\d+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } });
return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const U = (n) => server.url.origin + '/p' + n + '.png';
const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [], content: 'picture post ' + (i + 1) + ' ' + U(i + 1) }, SK));
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round)
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 post the pager rests square on (its number), or 'between'
const at = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 1 && Math.abs(r.left) < 1; }); return s ? Number(/p(\d+)\.png/.exec(s.getAttribute('data-k'))[1]) : 'between'; });
const settled = async () => { let last = null; for (let i = 0; i < 100; i++) { /* (a long flight takes up to 4 s) */ const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } };
// a swipe up the screen: px of travel over about `ms` milliseconds. A
// quick one is dispatched inside the page, as fast as the handlers take
// the moves (about 30–50 ms each here, headless): the browser's own touch
// simulation takes a round trip per move on top, so a flick through it is
// never faster than a stroll.
const swipe = async (px, ms, steps = 6) => {
if (ms >= 100) {
await page.touchscreen.touchStart(195, 700);
for (let i = 1; i <= steps; i++) { await page.touchscreen.touchMove(195, 700 - px * i / steps); await sleep(ms / steps); }
await page.touchscreen.touchEnd();
return;
}
await page.evaluate(([px, ms, steps]) => {
const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el;
const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
const fire = (type, y, up) => target.dispatchEvent(new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] }));
// on a schedule from the start, not a pause after each move: a move's
// handling (the slides following the finger) takes its own time
const t0 = performance.now();
fire('touchstart', 700);
for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); fire('touchmove', 700 - px * i / steps); }
fire('touchend', 700 - px, true);
}, [px, ms, steps]);
};
// A swipe whose moves are made on schedule (each event stamped as the finger
// moved) but handled late, the main thread busy: held back and delivered in
// a burst at the end (burst), or the last move and the lift after a stall.
const queued = (px, ms, steps, mode) => page.evaluate(([px, ms, steps, mode]) => {
const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el;
const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
const make = (type, y, up) => new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] });
const t0 = performance.now(), evs = [make('touchstart', 700)];
for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); evs.push(make('touchmove', 700 - px * i / steps)); }
evs.push(make('touchend', 700 - px, true));
if (mode === 'burst') { for (const e of evs) target.dispatchEvent(e); return; }
// stall: all but the last move handled as they come, then 300 ms busy
const tail = evs.splice(-2);
for (const e of evs) target.dispatchEvent(e);
const t1 = performance.now(); while (performance.now() < t1 + 300);
for (const e of tail) target.dispatchEvent(e);
}, [px, ms, steps, mode]);
try {
await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: 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: '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 >= 3, null, 20000));
const pic = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture post 1 ')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
await page.touchscreen.tap(pic.x, pic.y);
check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && (await (async () => { await settled(); return at(); })()) === 1, String(await at()));
check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 15000));
check('…and decoded, in the carousel (ten ready before it flies)', await waitFor((n) => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= n, N, 10000), String(await page.evaluate(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length)));
console.log('\n[a gentle swipe: one post]');
await swipe(320, 130); await settled();
check('a 320px swipe over 130ms: the next post', (await at()) === 2, String(await at()));
console.log('\n[a fast flick: several posts, landing snapped]');
const trace = async (sw) => { await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push(sc.scrollTop); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); }); await sw(); await sleep(1600); return page.evaluate(() => window.__tr); };
await page.evaluate(() => { const el = document.querySelector('.mp-position'); window.__counts = [el.textContent]; new MutationObserver(() => { if (window.__counts.at(-1) !== el.textContent) window.__counts.push(el.textContent); }).observe(el, { childList: true, characterData: true, subtree: true }); });
const tr = await trace(() => swipe(600, 80, 3)); await settled(); // three big moves: 3–6 px/ms as their handling takes, a long coast's worth (past the nine loaded, maybe)
const landed = await at();
check('a 600px flick in a few frames flies several posts on', landed !== 'between' && landed >= 5, String(landed));
check('…landing square on a post', landed !== 'between');
check('…in a glide, never back', tr.every((x, i) => !i || x >= tr[i - 1] - 0.5), tr.slice(0, 12).map((x) => Math.round(x)).join(' '));
check('the counter ran through the posts on the way, not jumping on landing', (await page.evaluate(() => window.__counts)).length >= 4, (await page.evaluate(() => window.__counts)).join(' → '));
const seen = await page.evaluate(() => document.querySelector('.mp-position').textContent);
check('the counter says where it landed', seen === landed + ' / ' + N, seen);
console.log('\n[the ends are ends]');
while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); }
const before = await at();
await swipe(600, 80, 3); await settled();
check('from post ' + before + ', a flick whose flight would be past the end lands on the last post', before === 7 && (await at()) === N, String(await at()));
await swipe(600, 80, 3); await settled();
check('…a flick on from the last stays there (no going round to the first)', (await at()) === N, String(await at()));
await page.keyboard.press('ArrowDown'); await settled();
check('…and so does ↓', (await at()) === N, String(await at()));
while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
await swipe(-600, 80, 3); await settled();
check('a flick back up from the first stays on the first', (await at()) === 1, String(await at()));
await swipe(320, 130); await settled();
check('a gentle swipe from the first: the second, as ever', (await at()) === 2, String(await at()));
console.log('\n[a busy main thread: the speed is the finger\'s, not the handling\'s]');
// (the dispatch loop runs all the events in one go: built on schedule, each stamped as it was made)
while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
await queued(200, 200, 8, 'burst'); await settled();
check('from post 1, a gentle 200px swipe over 200ms, its moves handled in one burst: one post on', (await at()) === 2, String(await at()));
await queued(600, 80, 4, 'stall'); await settled();
check('a fast flick whose last move was handled 300ms late still flies', typeof (await at()) === 'number' && (await at()) >= 5, String(await at()));
while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
console.log('\n[a tap mid-flight stops it on the post most on screen]');
// the post most on screen as the finger comes down (its number), then the touch, in one go
const down = () => page.evaluate(() => {
const sc = document.querySelector('.mp-scroll'), y = sc.scrollTop;
const g = [...sc.querySelectorAll(':scope > .mp-group:not(.mp-wait):not(.mp-off)')].reduce((b, x) => (Math.abs(x.offsetTop - y) < Math.abs(b.offsetTop - y) ? x : b));
const target = window.__tt = document.elementFromPoint(195, 500), t = new Touch({ identifier: 2, target, clientX: 195, clientY: 500, pageX: 195, pageY: 500 });
target.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], changedTouches: [t], targetTouches: [t] }));
return Number(/p(\d+)\.png/.exec(g.querySelector('.mp-slide').getAttribute('data-k'))[1]);
});
// the same finger moving (to y), or lifted there
const finger = (y, up) => page.evaluate(([y, up]) => {
const target = window.__tt, t = new Touch({ identifier: 2, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
target.dispatchEvent(new TouchEvent(up ? 'touchend' : 'touchmove', { bubbles: true, cancelable: true, touches: up ? [] : [t], changedTouches: [t], targetTouches: up ? [] : [t] }));
}, [y, up]);
await swipe(600, 80, 3);
await sleep(150);
const flying = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
const most = await down();
await sleep(60); await finger(500, true);
await sleep(250);
check('a tap mid-flight: on the post most on screen in a moment', flying > 0 && (await at()) === most, most + ' wanted, ' + (await at()));
await settled();
check('…and stays there', (await at()) === most, String(await at()));
check('…the tap only stopped it (no details brought up)', !(await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show'))));
console.log('\n[a swipe back mid-flight: the post before]');
while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
await swipe(600, 80, 3);
await sleep(150);
const most2 = await down();
for (const y of [520, 560, 640, 760]) { await finger(y); await sleep(20); }
await finger(760, true); await settled();
check('caught on post ' + most2 + ' and swiped back: post ' + (most2 - 1), most2 > 1 && (await at()) === most2 - 1, String(await at()));
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✅ a fast flick flies through the pager' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|