aboutsummaryrefslogtreecommitdiffstats
path: root/tools/pager-eager-test.js
blob: 15a50764de70125fc37e591ea3ccadcf3830ac66 (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
// 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.
// Run: bun tools/pager-eager-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, 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; };

// big clips: noise at a high bitrate, ~35 MB each — over the 25 MB budget
const BIG = '/tmp/coinos-eager-big.webm';
if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'nullsrc=s=180x320:r=15,geq=random(1)*255:128:128', '-t', '50', '-c:v', 'libvpx', '-b:v', '8M', '-minrate', '8M', '-maxrate', '8M', '-bufsize', '2M', 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(saveData, slow = false) {
  console.log('\n[' + (saveData ? 'Data Saver on' : slow ? 'a link slower than the clips (3 Mbps)' : 'an ordinary connection') + ']');
  log = [];
  const ctx = await browser.createBrowserContext(); // a cache of its own
  const page = await ctx.newPage();
  await page.evaluateOnNewDocument((sd) => {
    if (sd) Object.defineProperty(navigator, 'connection', { configurable: true, get: () => ({ saveData: true, effectiveType: '4g', addEventListener() {}, removeEventListener() {} }) });
    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);
  }, saveData);
  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 slide = (k) => 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 s ? { cover: !!s.querySelector('.vid-heavy'), src: !!(v && v.getAttribute('src')), preload: v && v.preload, playing: !!(v && !v.paused && v.currentTime > 0) } : null; }, k);
  const feedRow = (w) => page.evaluate((x) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = r.querySelector('video.note-video').getBoundingClientRect(); return { cover: !!r.querySelector('.vid-heavy'), x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }, w);
  try {
    await page.setViewport({ width: 390, height: 844 });
    pace = slow ? 375000 : 0;
    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: 'Eager 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 });

    // in the feed the big clip waits under its cover; a tap on that plays it there, a tap on the clip opens the pager
    await feedRow('clip c0 here'); await sleep(1500);
    // (with Data Saver nothing autoplays in the feed: the player's own play button, not a cover)
    if (!saveData) check('in the feed, a clip over budget waits under its cover', (await feedRow('clip c0 here')).cover);
    await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); const b = r.querySelector('.vid-heavy'); if (b) b.click(); else r.querySelector('video.note-video').play(); });
    await waitFor(() => { const v = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')).querySelector('video.note-video'); return v && !v.paused && v.currentTime > 0; }, null, 10000);
    const r = await feedRow('clip c0 here');
    log = log.filter((e) => e.clip === 'c0');
    await page.mouse.click(r.x, r.y);
    check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
    await sleep(slow ? 8000 : 3000);
    const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3');
    let head1 = 0;
    if (!saveData) {
      check('the next clips wait on their pictures, no cover, no player yet', c1 && !c1.src && !c1.cover && c2 && !c2.src, JSON.stringify([c1, c2]));
      const hs = heads('c1'), h2 = heads('c2');
      head1 = hs.length ? hs[0].end + 1 : 0;
      const head2 = h2.length ? h2[0].end + 1 : 0;
      // before any measure of the link: two seconds' worth (the second clip ahead, half)
      check('the next clip\'s opening is fetched ahead: two seconds of it', hs.length === 1 && hs[0].start === 0 && near(head1, 2 * RATE), `${kb(head1)} (two seconds: ${kb(2 * RATE)})`);
      check('…and half that of the one after', h2.length === 1 && near(head2, RATE), `${kb(head2)}`);
      check('nothing more than that ahead of time', sent('c1') <= head1 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
    } else {
      check('nothing loads ahead of a clip over budget', c1 && !c1.src && sent('c1') === 0, JSON.stringify(c1) + ' ' + kb(sent('c1')));
    }
    const before = log.length;
    await page.keyboard.press('ArrowDown');
    const t0 = Date.now();
    if (!saveData) {
      check('swiped to, the next clip plays at once, no cover', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s.querySelector('video.mp-media'); return !s.querySelector('.vid-heavy') && !v.paused && v.currentTime > 0; }, null, 5000) && (Date.now() - t0) < (slow ? 3000 : 1500), (Date.now() - t0) + ' ms');
      const after = log.slice(before).filter((e) => e.clip === 'c1' && e.dest === 'video');
      check('…its player starting where the head start left off (the opening came from the cache)', after.length >= 1 && after[0].start === head1, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing') + ', head start ' + head1);
      await sleep(1500);
      const h3 = heads('c3'), head3 = h3.length ? h3[0].end + 1 : 0;
      check('…the next one gets its head start', h3.length === 1);
      // the link measured now: a fast one, still a couple of seconds' worth; one slower than the clip, the cap
      if (!slow) check('…a fast link measured: still two seconds\' worth (half, as the second ahead)', near(head3, RATE), kb(head3));
      else check('…the link measured slower than the clip: it grows to the cap (half, as the second ahead)', head3 === HEAD_MAX / 2, kb(head3));
    } else {
      await sleep(3000);
      const s = await slide('c1');
      check('swiped to, it waits under its cover', s && s.cover && !s.playing, JSON.stringify(s));
    }
    check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
  } finally { await ctx.close(); }
}
try { await run(false); await run(false, true); await run(true); } finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ the pager plays at once and gets a head start on what\'s next, unless saving data' : '\n❌ failed');
process.exit(ok ? 0 : 1);