aboutsummaryrefslogtreecommitdiffstats
path: root/tools/pager-prime-test.js
blob: 7263ee170137bbd508a746856ead905f40d1416e (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
// A warm player's sound is started before the swipe lands (an Android
// phone held a still picture ~250 ms on a player's first play with sound):
// the next clip plays at a volume nobody hears until its picture moves,
// and pauses there, a frame or two in — not seeked back. Clips over 25 MB
// aren't (once played, a player reads the whole file): pager-eager-test.
// Run: bun tools/pager-prime-test.js
//
// (adapted from pager-eager-test:)
// Flipping through clips in the pager is asking to watch them: a clip plays
// at once whatever it costs — even one over the size budget, which in the
// feed waits under a tap-to-play cover — and the next two get a head start:
// their opening fetched into the cache, no more of a big file than that, and
// the player takes it from there. How much: a couple of seconds' worth of
// the clip on a link well ahead of its bitrate (the second clip ahead, half
// that); on a link slower than the clip, more — to the 4 MB cap. With Data
// Saver on, the pager is careful again: such a clip waits for a tap and
// nothing loads ahead.
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, statSync } 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; };

// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
const BIG = '/tmp/coinos-prime-small.webm';
if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
const SIZE = statSync(BIG).size;
const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];

const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
const html = await buildHtml({ minify: true, pwa: false });
// every request for a clip's bytes: who asked (the player, or a fetch), the
// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
let log = [];
let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
  const path = new URL(req.url).pathname;
  const m = /^\/(c\d)\.webm$/.exec(path);
  if (m) {
    const headers = { 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
    if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
    const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
    const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
    const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
    log.push(entry);
    let at = start;
    const t0 = Date.now();
    const body = new ReadableStream({ async pull(ctl) {
      if (at > end) { ctl.close(); return; }
      if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
      const n = Math.min(65536, end - at + 1);
      ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
    } }, { highWaterMark: 0 });
    return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
  }
  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 seeded = [
  // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
  ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
  ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
];
const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
const kb = (n) => Math.round(n / 1024) + ' KB';
const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;

const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
async function run() {
  log = [];
  const ctx = await browser.createBrowserContext();
  const page = await ctx.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);
  });
  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 vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
  try {
    await page.setViewport({ width: 390, height: 844 });
    pace = 12_500_000;
    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]) => {
      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: 'Prime Poster', t: Date.now() } }));
      localStorage.setItem('btc-wallet-feed:' + k, 'following');
    }, [base, AUTHOR, seeded]);
    await page.reload({ waitUntil: 'domcontentloaded' });
    await waitText('receive', 20000);
    await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
    await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
    const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; });
    await sleep(800);
    await page.mouse.click(r.x, r.y);
    check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
    check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1')));
    const c1 = await vid('c1');
    check('...its volume back to full, and quiet until it\'s on screen', c1.volume === 1 && c1.muted, JSON.stringify(c1));
    await page.keyboard.press('ArrowDown');
    await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 5000);
    const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
    check('swiped to, it plays with sound, primed', st && st.primed && st.warm && st.ms < 1000 && await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return !v.muted && v.volume === 1 && !v.paused; }), JSON.stringify(st));
    check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
  } finally { await ctx.close(); }
}
try { await run(); } finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a warm player has its sound going before the swipe lands' : '\n❌ failed');
process.exit(ok ? 0 : 1);