aboutsummaryrefslogtreecommitdiffstats
path: root/tools/media-moments-feed-test.js
blob: f2d2774ef54f523c0d40f05c3cc1f0be5aa91606 (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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// Full feed integration with local media and relay acknowledgements; no events leave the test.
// Run: bun tools/media-moments-feed-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 { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
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 dir = mkdtempSync(tmpdir() + '/media-');
const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
// index last (no faststart), from a server that ignores ranges
ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);

ff(['-f', 'lavfi', '-i', 'sine=frequency=440:duration=12', `${dir}/audio.mp3`]);
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => {
  const path = new URL(req.url).pathname;
  if (path === '/audio.mp3') {
    const f = Bun.file(dir + '/audio.mp3'), m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
    if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'audio/mpeg', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); }
    return new Response(f, { headers: { 'content-type': 'audio/mpeg' } });
  }
  if (path.startsWith('/v/')) {
    const f = Bun.file(dir + path.slice(2));
    const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
    if (m && path !== '/v/slow.mp4') {
      const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1);
      // c.mp4 stands in for a 50 MB file: its server says so
      const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file)
      return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } });
    }
    return new Response(f, { headers: { 'content-type': 'video/mp4' } });
  }
  if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } });
  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 base = server.url.origin;
const SK = generateSecretKey(), PK = getPublicKey(SK);
const FAN = generateSecretKey();
const now = Math.floor(Date.now() / 1000);
const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK);
const notes = [
  post(0, 'first clip, look at this ' + base + '/v/a.mp4'),
  post(1, 'a plain post with no media at all'),
  post(2, 'sunset over the bay ' + base + '/i/p1.jpg'),
  post(3, base + '/v/b.mp4'),
  post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'),
  // the post says this one is 50 MB (it isn't; the post is believed)
  post(5, 'a huge clip ' + base + '/v/c.mp4', [['imeta', 'url ' + base + '/v/c.mp4', 'm video/mp4', 'size 50000000', 'dim 360x640']]),
  post(6, 'index last ' + base + '/v/slow.mp4'),
];
notes.unshift(post(9, 'Listen ' + base + '/audio.mp3', [['imeta', 'url ' + base + '/audio.mp3', 'waveform 1 5 2 8 4 10']]));
const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN);

const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument((like) => {
  const Real = window.WebSocket;
  window.WebSocket = function (...a) {
    const ws = new Real(...a);
    const send = ws.send.bind(ws);
    ws.send = (data) => {
      try {
        const m = JSON.parse(data);
        if (m[0] === 'REQ') {
          const f = m[2] || {};
          const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : [];
          setTimeout(() => {
            for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
            ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
          }, 0);
          return;
        }
        if (m[0] === 'EVENT') return send(data);
      } catch {}
      return send(data);
    };
    return ws;
  };
  window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
}, LIKE);
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, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m);
const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); };
// which slide fills the screen, and what's playing
const pagerState = () => page.evaluate(() => {
  const p = document.querySelector('.media-pager');
  if (!p) return null;
  const sc = p.querySelector('.mp-scroll');
  const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
  const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
  const vids = slides.map((s) => s.querySelector('video'));
  return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
    playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'),
    meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity };
});

try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto(base, { 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 key = cacheKeyFor(mn + '\n');
  await page.evaluate(([k, pk, 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: 'Shutterbug', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [key, PK, notes]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
  await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });


  await page.waitForSelector('.note-audio audio');
  await page.evaluate(() => document.querySelector('.note-audio audio').play());
  await page.waitForSelector('.note-audio .media-timeline');
  await page.waitForFunction(() => document.querySelectorAll('.note-audio .media-wave i').length === 6);
  check('published audio waveform renders in the actual feed', true);
  await page.evaluate(() => { const a = document.querySelector('.note-audio audio'); a.pause(); a.currentTime = 5; });
  await sleep(100);
  await page.evaluate(() => document.querySelectorAll('.note-audio .media-moment-actions button')[1].click());
  await page.waitForSelector('.msg-sheet');
  await page.evaluate(() => document.querySelector('.msg-sheet button').click());
  await sleep(400);
  const reaction = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'media')));
  check('reaction is signed with the selected file and position', reaction?.tags.some(t => t[0] === 'media' && t[2] === '5'));
  check('reaction marker appears while paused', await page.evaluate(() => !!document.querySelector('.note-audio .media-mark')));
  await page.screenshot({ path: '/tmp/media-timeline-playing.png' });
  await page.evaluate(() => document.querySelector('.note-audio .media-moment-actions button').click());
  await page.waitForSelector('.thread-reply-input');
  check('timestamp is visible in the reply composer', (await page.evaluate(() => document.body.innerText)).includes('Comment at 0:05'));
  await page.evaluate(() => { const input = document.querySelector('.thread-reply-input'); input.value = 'Great moment'; input.dispatchEvent(new Event('input', { bubbles: true })); });
  await page.click('.thread-reply-send');
  await sleep(500);
  const comment = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 1 && e.content.includes('Great moment')));
  check('comment includes a readable timestamp and signed media context', comment?.content === '[0:05] Great moment' && comment.tags.some(t => t[0] === 'media' && t[2] === '5'));
  check('comment shows a jump-to-time link', await page.evaluate(() => !!document.querySelector('.media-time-link')));
  await page.evaluate(() => document.querySelector('.media-time-link').click());
  await page.waitForFunction(() => document.querySelector('.note-audio audio')?.currentTime >= 5);
  check('the comment link starts playback at its timestamp', true);
  await click('back'); await sleep(250);
  await page.evaluate(() => document.querySelector('video.note-video').play());
  await page.waitForSelector('.note-video-box .media-moment-actions');
  await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.currentTime = 1; });
  await sleep(150);
  await page.evaluate(() => document.querySelectorAll('.note-video-box .media-moment-actions button')[1].click());
  await page.waitForSelector('.msg-sheet');
  await page.evaluate(() => document.querySelector('.msg-sheet button').click());
  await sleep(300);
  check('video reaction carries the video URL and selected second', await page.evaluate(() => window.__relayGuard.events.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'media' && t[1].endsWith('.mp4') && t[2] === '1'))));
  check('no browser exceptions', errs.length === 0, errs.join(' | '));
  await page.screenshot({ path: '/tmp/media-timeline-feed.png' });
} finally { await browser.close(); server.stop(); }
process.exit(ok ? 0 : 1);