aboutsummaryrefslogtreecommitdiffstats
path: root/tools/video-events-test.js
blob: bef0956c3864098c0f4830717b8c696df5416b85 (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
136
137
138
139
// NIP-71 video events in the feed: a clip posted as itself (kind 21, 22, and
// the addressable 34235/34236 divine.video publishes) is asked for with the
// notes and drawn as a post — its title, its words, and the clip from its
// imeta tag (the version nearest 1080, its poster), even a file whose name
// doesn't say it's a video. An addressable clip shows once, not once per
// version. A note linking such a file, typed video/* in its imeta, plays it
// too. And the clips are in the pager.
// Run: bun tools/video-events-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';
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 = '/tmp/coinos-events-clip.webm';
if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=180x320:rate=10', '-t', '10', '-c:v', 'libvpx', '-b:v', '100k', CLIP]);
const PNG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=180x320', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;

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('/v/')) return new Response(Bun.file(CLIP), { headers: { 'content-type': 'video/webm' } });
  if (path.endsWith('.png')) return new Response(PNG, { 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 O = server.url.origin;
const ev = (kind, content, tags, ago) => finalizeEvent({ kind, created_at: now - ago, tags, content }, SK);
const EVENTS = [
  // a normal video event: two versions on offer, a 4K and a 720p — the 720p is the one
  ev(21, 'about the normal one', [['title', 'A normal clip'],
    ['imeta', 'url ' + O + '/v/normal-4k.mp4', 'm video/mp4', 'dim 3840x2160'],
    ['imeta', 'url ' + O + '/v/normal-720.mp4', 'm video/mp4', 'dim 1280x720', 'image ' + O + '/normal.png']], 100),
  // divine.video's way: addressable, a file with no extension, a poster
  ev(34236, '', [['d', 'vine-1'], ['title', 'Short one'], ['imeta', 'url ' + O + '/v/shortfile', 'm video/mp4', 'image ' + O + '/short.png', 'dim 180x320']], 200),
  // ...and an older version of the same one
  ev(34236, '', [['d', 'vine-1'], ['title', 'old version'], ['imeta', 'url ' + O + '/v/shortfile-old', 'm video/mp4']], 900),
  // a note linking a file whose name doesn't say, typed in its imeta
  ev(1, 'look at this ' + O + '/v/notefile', [['imeta', 'url ' + O + '/v/notefile', 'm video/mp4', 'dim 180x320']], 300),
  ev(1, 'just words, nothing to watch here at all', [], 400),
];

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((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('#'));
  window.__kindsAsked = new Set();
  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;
          if (f.authors) for (const k of f.kinds || []) window.__kindsAsked.add(k);
          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;
}, EVENTS);
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 row = (w) => page.evaluate((x) => {
  const rs = [...document.querySelectorAll('.notes-feed > [data-key]')].filter((n) => n.textContent.includes(x));
  const r = rs[0]; const v = r && r.querySelector('video.note-video');
  return r ? { n: rs.length, video: v ? (v.getAttribute('src') || '').replace(location.origin, '') : null, poster: v ? (v.getAttribute('poster') || '').replace(location.origin, '') : null, text: r.querySelector('.note-text')?.innerText.trim().slice(0, 120) } : null;
}, w);

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]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Maker', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, AUTHOR]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  check('the feed fills', await waitFor(() => [...document.querySelectorAll('.notes-feed > [data-key]')].some((n) => n.textContent.includes('just words')), null, 20000));
  await sleep(1500);
  check('the feed asks for video events with the notes', await page.evaluate(() => [21, 22, 34235, 34236].every((k) => window.__kindsAsked.has(k))), JSON.stringify(await page.evaluate(() => [...window.__kindsAsked])));

  console.log('\n[a kind 21 video event]');
  let r = await row('A normal clip');
  check('it is a post: its title and its words', r && /A normal clip/.test(r.text) && /about the normal one/.test(r.text), JSON.stringify(r));
  check('…with its clip — the version nearest 1080, not the 4K', r && r.video === '/v/normal-720.mp4', JSON.stringify(r));
  check('…its poster from the imeta', r && r.poster === '/normal.png', JSON.stringify(r));
  check('not shown as a kind this app can\'t draw', !(await page.evaluate(() => /kind 21|kind 34236/i.test(document.querySelector('.notes-feed').innerText))));

  console.log('\n[a divine.video clip (34236): addressable, a file with no extension]');
  r = await row('Short one');
  check('it plays: the file is a clip though its name doesn\'t say', r && r.video === '/v/shortfile', JSON.stringify(r));
  check('…with its poster', r && r.poster === '/short.png', JSON.stringify(r));
  check('…once: its older version isn\'t shown as well', r && r.n === 1 && !(await row('old version')), JSON.stringify([r, await row('old version')]));

  console.log('\n[a note linking such a file, typed in its imeta]');
  r = await row('look at this');
  check('it plays the file', r && r.video === '/v/notefile', JSON.stringify(r));

  console.log('\n[the pager has them]');
  await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
  await sleep(500);
  const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
  await page.mouse.click(at.x, at.y);
  check('a tap opens the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
  const slides = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('|')[1].replace(location.origin, '')));
  check('…over the three clips, in the feed\'s order', JSON.stringify(slides) === JSON.stringify(['/v/normal-720.mp4', '/v/shortfile', '/v/notefile']), JSON.stringify(slides));
  check('…the caption, the title and words (no file link)', await page.evaluate(() => { const c = document.querySelector('.mp-slide .mp-caption'); return !!c && /A normal clip/.test(c.textContent) && !/\/v\//.test(c.textContent); }));
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ video events are posts with their clips' : '\n❌ failed');
process.exit(ok ? 0 : 1);