aboutsummaryrefslogtreecommitdiffstats
path: root/tools/thread-pager-test.js
blob: 0f9974227e219c0439ad9ec4f415c2c28e9bbea7 (plain) (blame)
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
// A picture tapped on a thread page opens in the media pager over the
// THREAD's pictures — the root's and its replies', in the page's order,
// nobody else's — with the caption and controls a tap away, and closing it
// leaves the thread on screen. (It opened alone, in the lightbox, before:
// a tap on it closed it, with no caption or controls — Adam, 2026-10-16.)
// Run: bun tools/thread-pager-test.js
import puppeteer from './puppeteer.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'), x: png('200x200') };
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 === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } });
  if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
  const m = /^\/(a1|a2|b|d|x)\.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; charset=utf-8' } });
} });
const U = (n) => server.url.origin + '/' + n + '.png';
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2);
const now = Math.floor(Date.now() / 1000);
const post = (content, ago, sk = SK, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
const root = post('two pictures in the root ' + U('a1') + ' ' + U('a2'), 600);
const replyWords = post('a reply with only words', 500, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]);
const replyPic = post('a reply with a picture ' + U('b'), 400, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]);
const feedNotes = [
  post('someone else, newer, with a picture ' + U('x'), 100, SK2),
  post('another post with a picture ' + U('d'), 200),
  root,
  ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 700 + i * 60)),
];
const replies = [replyWords, replyPic];
const profiles = [finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Picture Poster' }) }, SK),
  finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Reply Writer' }) }, SK2)];
const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes: feedNotes, profiles, popUntil: now - 3600 });

const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
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 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) => 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 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 openPicIn = async (scope, word, nth = 0) => {
  await page.evaluate(([sc, w]) => { const r = [...document.querySelectorAll(sc)].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, [scope, word]);
  await sleep(400);
  const at = await page.evaluate(([sc, w, n]) => { const r = [...document.querySelectorAll(sc)].find((x) => x.textContent.includes(w)); const i = r.querySelectorAll('img.note-img')[n].getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, [scope, word, nth]);
  await page.mouse.click(at.x, at.y);
  await waitFor(() => !!document.querySelector('.media-pager, .lightbox'), null, 4000);
};
const threadRowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.thread-page [data-zap-post]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word);

try {
  await page.setViewport({ width: 390, height: 844, hasTouch: true });
  await page.setRequestInterception(true);
  page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort());
  await page.evaluateOnNewDocument(([notes, replies, profiles]) => {
    class FakeSocket extends EventTarget {
      constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set();
        setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); }
      emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
      message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); }
      send(raw) {
        let m; try { m = JSON.parse(raw); } catch { return; }
        if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
        if (m[0] !== 'REQ') return;
        const [, id, f] = m; this.subs.add(id);
        let evs = [];
        const all = [...notes, ...replies];
        if (f['#e']) evs = replies.filter((e) => e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1])));
        else if (f.ids) evs = all.filter((e) => f.ids.includes(e.id));
        else if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) && (!f.authors || f.authors.includes(e.pubkey))).slice(0, f.limit || 100);
        if (f.kinds?.includes(0)) evs = profiles.filter((p) => !f.authors || f.authors.includes(p.pubkey));
        setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30);
      }
      close() { this.readyState = 3; this.emit('close', new Event('close')); }
    }
    Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
    window.WebSocket = FakeSocket;
  }, [feedNotes, replies, profiles]);
  await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' });
  check('the feed shows the posts', await waitFor(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 10, null, 20000));
  await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); r.scrollIntoView({ block: 'center' }); });
  await sleep(300);
  await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); (r.querySelector('.note-text') || r).click(); });
  check('the thread opens', await waitFor(() => !!document.querySelector('.thread-page'), null, 5000));
  check('…with its replies', await waitFor(() => document.body.innerText.includes('a reply with a picture'), null, 8000));
  await sleep(500);

  console.log('\n[a picture on a thread page]');
  await openPicIn('.thread-page [data-zap-post]', 'two pictures in the root');
  check('it opens in the media pager, not the lightbox', await page.evaluate(() => !!document.querySelector('.media-pager') && !document.querySelector('.lightbox')));
  check('…on itself', await resting('a1.png'), await curPic());
  const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop()));
  check('the pager holds the thread\'s pictures only, root first then the replies', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png']), JSON.stringify(keys));
  check('counter: first of two posts', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 2');
  check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show')));
  await tapPager(); await sleep(400);
  check('a tap shows the caption and controls', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
  check('…with the author and the words', 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; }); return /Picture Poster/.test(s.textContent) && /two pictures in the root/.test(s.textContent); }));
  await page.keyboard.press('ArrowRight');
  check('right: the root\'s second picture', await resting('a2.png'), await curPic());
  await page.keyboard.press('ArrowDown');
  check('down: the reply\'s picture', await resting('b.png'), await curPic());
  check('counter: second post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 2');
  check('no loading slide (nothing more to fetch)', await page.$('.mp-tail') === null);
  await page.keyboard.press('Escape');
  check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
  await sleep(600);
  check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a reply with only words')));
  check('…with the reply\'s post on screen', (await threadRowTop('a reply with a picture')) !== null && (await threadRowTop('a reply with a picture')) >= -10 && (await threadRowTop('a reply with a picture')) < 844, 'row top ' + (await threadRowTop('a reply with a picture')));

  console.log('\n[a reply\'s picture, and a tap then another]');
  await openPicIn('.thread-page [data-zap-post]', 'a reply with a picture');
  check('opens on the reply\'s picture', await resting('b.png'), await curPic());
  await tapPager(); await sleep(400);
  check('a tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
  await tapPager(); await sleep(600);
  check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
  check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page')));
  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 thread\'s pictures page through the pager' : '\n❌ failed');
process.exit(ok ? 0 : 1);