aboutsummaryrefslogtreecommitdiffstats
path: root/tools/pager-caption-test.js
blob: 5952d81065f811fea032134a1da2b0ccc8931c54 (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
// The video pager's caption is the post's words with their names, tags and
// links live: a tap on @someone leaves the pager and opens their profile
// (a plain excerpt left "@NostrValley" dead); a tap anywhere else on the
// caption leaves the pager and opens the post, where the whole caption
// reads. A link to the web opens in its own tab and the pager stays.
// Run: bun tools/pager-caption-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
import { buildHtml } from '../build.js';
import { existsSync } from 'node:fs';
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 clip = (name, size) => {
  const f = '/tmp/coinos-vpager-' + name + '.webm';
  if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
  return f;
};
const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320') };
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), VALLEY = getPublicKey(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;
  const m = /^\/(wide|tall)\.webm$/.exec(path);
  if (m) return new Response(Bun.file(CLIPS[m[1]]), { headers: { 'content-type': 'video/webm' } });
  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 + '.webm';
const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
const seeded = [
  post('Opening night nostr:' + npubEncode(VALLEY) + ' downtown entertainment, a long night of it with music and more ' + U('wide'), 100),
  post('a tall one https://example.com/about ' + U('tall'), 200),
  ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4), 600 + i * 60)),
];

const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
  const Real = window.WebSocket;
  window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
  window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
  window.__opened = [];
  window.open = (u) => { window.__opened.push(String(u)); return null; };
});
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 openClip = async (word) => {
  await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').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 b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
  await page.mouse.click(at.x, at.y);
  const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000);
  // the caption and the counts show on a tap on the clip (details), as a reader sees them
  await sleep(500);
  await page.mouse.click(195, 300);
  await waitFor(() => !!document.querySelector('.media-pager.show'), null, 3000);
  return opened;
};
// the caption of the slide on screen
const CAP = () => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => Math.abs(x.getBoundingClientRect().top) < 4); return s && s.querySelector('.mp-caption'); };
const caption = () => page.evaluate((cap) => { const c = eval(cap)(); return c ? { text: c.innerText, links: [...c.querySelectorAll('a')].map((a) => a.textContent) } : null; }, '(' + CAP + ')');
const clickIn = async (sel, where) => {
  const at = await page.evaluate(([cap, sel, where]) => {
    const c = eval(cap)(); const el = sel ? c.querySelector(sel) : c; const r = el.getBoundingClientRect();
    // the caption's first word sits at its left edge; a link in the middle
    return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 };
  }, ['(' + CAP + ')', sel, where]);
  await page.mouse.click(at.x, at.y);
};
const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); };

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, pk, valley, 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: 'OpenMike', t: Date.now() }, [valley]: { name: 'NostrValley', about: 'the valley bio here', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, AUTHOR, VALLEY, seeded]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await toFeed();

  console.log('\n[a name in the caption]');
  check('the pager opens on the clip', await openClip('Opening night'));
  await sleep(500);
  let c = await caption();
  check('the caption shows the post\'s words with the name in them', !!c && /Opening night @NostrValley downtown/.test(c.text), JSON.stringify(c));
  check('…and the name is a link', !!c && c.links.includes('@NostrValley'), JSON.stringify(c));
  await clickIn('a', 'middle');
  check('a tap on the name leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
  check('…out of full screen', await page.evaluate(() => !document.fullscreenElement));
  check('…on their profile', await waitText('the valley bio here', 6000));

  console.log('\n[the caption\'s own words]');
  await toFeed();
  check('the pager opens again', await openClip('Opening night'));
  await sleep(500);
  await clickIn(null, 'start');
  check('a tap on the words leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000));
  check('…and opens the post', await waitFor(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a long night of it with music and more'), null, 6000));

  console.log('\n[a link to the web]');
  await toFeed();
  check('the pager opens on the other clip', await openClip('a tall one'));
  await sleep(500);
  c = await caption();
  check('its link is a link', !!c && c.links.some((l) => /example\.com/.test(l)), JSON.stringify(c));
  const newTab = new Promise((r) => browser.once('targetcreated', (t) => r(t.url())));
  await clickIn('a[href^="http"]', 'middle');
  const tab = await Promise.race([newTab, sleep(3000).then(() => null)]);
  check('a tap on it opens the page in its own tab', !!tab && /example\.com\/about/.test(tab), String(tab));
  check('…and the pager stays open', await page.evaluate(() => !!document.querySelector('.media-pager') && location.pathname !== '/about'));
  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\'s caption: names open profiles, words open the post, web links stay' : '\n❌ failed');
process.exit(ok ? 0 : 1);