aboutsummaryrefslogtreecommitdiffstats
path: root/tools/pager-eager-test.js
blob: 5d99b8f89fd7ba74e4d90ddce9a753c33bb3eb24 (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
// 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 players ready on their first frame and the opening half megabyte
// fetched into the cache, but no more of a big file than that. 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 = [];
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 body = new ReadableStream({ pull(ctl) {
      if (at > end) { ctl.close(); return; }
      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 = [
  ...NAMES.map((n, i) => post('clip ' + n + ' here ' + U(n), 100 + i * 100)),
  ...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 browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
async function run(saveData) {
  console.log('\n[' + (saveData ? 'Data Saver on' : '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 });
    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(3000);
    const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3');
    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 = log.filter((e) => e.clip === 'c1' && e.dest === 'empty');
      check('the next clip\'s opening is fetched ahead: half a megabyte', hs.length === 1 && hs[0].start === 0 && hs[0].end === 512 * 1024 - 1, JSON.stringify(hs.map(({ start, end, sent }) => ({ start, end, sent }))));
      check('…and the one after\'s', log.some((e) => e.clip === 'c2' && e.dest === 'empty' && e.end === 512 * 1024 - 1));
      check('nothing more than that ahead of time', sent('c1') <= 512 * 1024 && sent('c2') <= 512 * 1024 && 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) + ' 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 === 512 * 1024, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing'));
      await sleep(1500);
      check('…the next one gets its head start', log.some((e) => e.clip === 'c3' && e.dest === 'empty'));
    } 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(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);