aboutsummaryrefslogtreecommitdiffstats
path: root/tools/quote-pager-test.js
blob: 7f8b76c4fc4e232b3528762bdebd3f2a24b29e5b (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
// Pictures in a quoted post are in the pager too. A post quoting another
// draws the quoted one inside it; a tap on a picture there opened it alone
// (the pager's list held only the feed posts' own pictures), with nothing to
// swipe to. Now a post's row in the pager has, after its own pictures, those
// of the posts it quotes: left and right walk them, down goes on to the next post's, the
// quoted picture's details name its author, and closing lands on the post
// that quoted it.
// Run: bun tools/quote-pager-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { noteEncode } from 'nostr-tools/nip19';
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 PIC = png('400x300');

const A = generateSecretKey(), Q = generateSecretKey();
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 (/^\/\w+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } });
  if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
  return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const U = (n) => server.url.origin + '/' + n + '.png';
const ev = (sk, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, sk);
// the quoted post: someone not followed, two screenshots
const quotedPost = ev(Q, 'two screenshots ' + U('q1') + ' ' + U('q2'), 5000);
const FEED = [
  ev(A, 'my own picture ' + U('a') + ' and a store worth a look nostr:' + noteEncode(quotedPost.id), 100),
  ev(A, 'one more after it ' + U('b'), 200),
  ...Array.from({ length: 4 }, (_, i) => ev(A, 'filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 300 + i * 60)),
];
const ALL = [...FEED, quotedPost];

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)
await page.evaluateOnNewDocument((evs) => {
  const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
    && (!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#'));
  class FakeWS {
    constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); }
    send(raw) {
      const m = JSON.parse(raw);
      const reply = (x) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), 5);
      if (m[0] === 'REQ') {
        for (const f of m.slice(2)) {
          if (f.cache) continue;
          const hits = evs.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at);
          for (const e of f.limit ? hits.slice(0, f.limit) : hits) reply(['EVENT', m[1], e]);
        }
        reply(['EOSE', m[1]]);
      } else if (m[0] === 'EVENT') reply(['OK', m[1].id, true, '']);
      else if (m[0] === 'COUNT') reply(['COUNT', m[1], { count: 0 }]);
    }
    close() { this.readyState = 3; this.onclose && this.onclose({ code: 1000 }); }
    addEventListener(t, fn) { this['on' + t] = fn; }
    removeEventListener() {}
  }
  FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3;
  window.WebSocket = FakeWS;
}, ALL);
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; };
const AT = () => { 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('/').pop().replace('.png', '') : 'between'; };
const at = () => page.evaluate(AT);
const resting = (n) => waitFor(([f, x]) => eval(f)() === x, ['(' + AT + ')', n], 4000);

try {
  await page.setViewport({ width: 390, height: 844 });
  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, pa, pq]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pa]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pa]: { name: 'Quoter', t: Date.now() }, [pq]: { name: 'Store Maker', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, getPublicKey(A), getPublicKey(Q)]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  check('the quoted post\'s pictures are drawn inside the post', await waitFor(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); return r && [...r.querySelectorAll('img')].some((i) => /\/q1\.png$/.test(i.src) && i.complete && i.naturalWidth); }, null, 20000));
  await sleep(800);

  console.log('\n[a tap on a quoted picture]');
  const pos = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); const i = [...r.querySelectorAll('img')].find((x) => /\/q1\.png$/.test(x.src)); i.scrollIntoView({ block: 'center' }); const b = i.getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; });
  await sleep(500);
  const pos2 = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); const i = [...r.querySelectorAll('img')].find((x) => /\/q1\.png$/.test(x.src)); const b = i.getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; });
  await page.mouse.click(pos2.x, pos2.y);
  check('it opens in the pager (not alone)', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && !(await page.evaluate(() => !!document.querySelector('.lightbox'))));
  check('…on the quoted picture', await resting('q1'), await at());
  // the quote's pictures are in its post's row: its own first, then the quote's
  check('…in the post\'s row, three along', await page.evaluate(() => document.querySelector('.mp-group .mp-dots').children.length === 3));
  await page.keyboard.press('ArrowRight');
  check('right: the quoted post\'s second picture', await resting('q2'), await at());
  await page.keyboard.press('ArrowDown');
  check('down: on to the next post\'s', await resting('b'), await at());
  await page.keyboard.press('ArrowUp');
  check('up: back to the quoting post, where it was left', await resting('q2'), await at());
  await page.keyboard.press('ArrowLeft'); await resting('q1');
  await page.keyboard.press('ArrowLeft');
  check('left past the quote: the quoting post\'s own picture, before it', await resting('a'), await at());
  await page.keyboard.press('ArrowRight'); await resting('q1');
  await page.keyboard.press('ArrowRight'); await resting('q2');
  // details: a tap (moved on from the start, so the first tap shows them)
  await page.mouse.click(195, 300); await sleep(400);
  check('the quoted picture\'s details name its author', 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 /Store Maker/.test(s.textContent) && /two screenshots/.test(s.textContent); }));
  await page.keyboard.press('Escape');
  check('closing it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000));
  await sleep(1500);
  const top = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('my own picture')); return r ? Math.round(r.getBoundingClientRect().top) : null; });
  check('…lands on the post that quoted it', top !== null && top >= -10 && top < 120, 'row top ' + top);
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ quoted pictures page with the rest' : '\n❌ failed');
process.exit(ok ? 0 : 1);