aboutsummaryrefslogtreecommitdiffstats
path: root/tools/profile-pager-test.js
blob: 29341e73209bc1fc12c587e0c156df1ffe709fdb (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
135
// A picture tapped on someone's profile page opens in the media pager over
// THEIR pictures — the posts of their timeline, in its order, nobody
// else's — and closing it after moving on lands the profile on that post.
// (It opened alone, in the lightbox, before: no pager on a profile.)
// Run: bun tools/profile-pager-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'), x: png('200x200') };

const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2);
const now = Math.floor(Date.now() / 1000);
const post = (content, ago, sk = SK) => 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|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' } });
} });
const U = (n) => server.url.origin + '/' + n + '.png';
const seeded = [
  post('bob picture ' + U('x'), 50, SK2), // someone else's, newest in the feed: not in Alice's pager
  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('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'] });
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) => 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: 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, other]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk], ['p', other]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', about: 'alice bio here', t: Date.now() }, [other]: { name: 'Bob Poster', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, AUTHOR, seeded, OTHER]);
  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));

  console.log('\n[a picture on a profile page]');
  // Alice's profile, from her face on a feed row
  await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('one picture')); r.scrollIntoView({ block: 'center' }); });
  await sleep(300);
  const face = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('one picture')).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
  await page.mouse.click(face.x, face.y);
  check('her profile opens, with her posts', await waitText('alice bio here', 8000) && await waitFor(() => [...document.querySelectorAll('.notes-feed [data-key]')].some((r) => r.innerText.includes('two pictures')), null, 8000));
  check('…and not Bob\'s', !(await page.evaluate(() => document.body.innerText.includes('bob picture'))));
  await openPic('two pictures');
  check('her picture opens in the pager, 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 her pictures only, in her timeline\'s order', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png', 'd.png']), JSON.stringify(keys));
  check('counter starts at the first post', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
  await page.keyboard.press('ArrowRight');
  check('right: the post\'s second picture', await resting('a2.png'), await curPic());
  check('sideways keeps the same post number', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
  await page.keyboard.press('ArrowDown');
  check('down: her next post with a picture', await resting('b.png'), await curPic());
  check('down advances by one post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 3');
  await page.keyboard.press('ArrowDown');
  check('down again: her last', await resting('d.png'), await curPic());
  await page.keyboard.press('ArrowDown'); await sleep(600);
  check('past her last wraps to her first post', await resting('a2.png'), await curPic());
  check('counter counts the first post once', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
  check('no loading slide', await page.$('.mp-tail') === null);
  await page.keyboard.press('ArrowUp'); await resting('d.png');
  check('up from first wraps to last', await curPic() === 'd.png');
  check('counter at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
  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.touchscreen.touchEnd();
  check('touch swipe wraps to first post', await resting('a2.png'), await curPic());
  await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: -3, deltaMode: 1, bubbles: true, cancelable: true })));
  check('wheel wraps back to last post', await resting('d.png'), await curPic());
  await page.keyboard.press('ArrowUp');
  check('up: her post before', await resting('b.png'), await curPic());
  await page.keyboard.press('Escape');
  check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
  await sleep(900);
  check('…still on her profile', await page.evaluate(() => document.body.innerText.includes('alice bio here')));
  check('…landed on that post', (await rowTop('one picture')) !== null && (await rowTop('one picture')) < 120 && (await rowTop('one picture')) > -10, 'row top ' + (await rowTop('one picture')));
  await openPic('one picture');
  check('opened again on the picture it closed on', await resting('b.png'), await curPic());
  await tapPager(); await sleep(400);
  check('a tap on the picture that opened it shows its details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show')));
  await tapPager(); await sleep(600);
  check('…and the next closes it', await waitFor(() => !document.querySelector('.media-pager'), 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✅ a profile\'s pictures page through the pager' : '\n❌ failed');
process.exit(ok ? 0 : 1);