aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/pager-ready-gate-test.js140
-rw-r--r--tools/pager-tap-slop-test.js1
2 files changed, 141 insertions, 0 deletions
diff --git a/tools/pager-ready-gate-test.js b/tools/pager-ready-gate-test.js
new file mode 100644
index 0000000..ab2bcaf
--- /dev/null
+++ b/tools/pager-ready-gate-test.js
@@ -0,0 +1,140 @@
+// The video pager never lands on a black slide: a post joins the carousel
+// only once its clip is ready to play (its first frame on the slide, its
+// opening in the cache), the one tapped excepted; a post that joins above
+// the clip on screen doesn't move it. And the carousel — like the Videos
+// grid — shows the clips the screen's way round (tall ones on a phone held
+// upright), a square either way, until the toggle asks for all shapes.
+// Run: bun tools/pager-ready-gate-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 jpg = (c, size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=' + c + ':s=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+const clip = (name, size) => {
+ const f = '/tmp/coinos-vpager-' + name + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+ return Buffer.from(Bun.spawnSync(['cat', f]).stdout);
+};
+// name → [shape, dims, first-frame delay ms]
+const SHAPES = { tall1: ['180x320', 0], wide: ['320x180', 0], tall2: ['180x320', 3000], tall3: ['180x320', 0], sq: ['240x240', 0] };
+const CLIPS = Object.fromEntries(Object.entries(SHAPES).map(([n, [size]]) => [n, clip(size === '180x320' ? 'tall' : size === '320x180' ? 'wide' : 'last', size)]));
+const FIRST = Object.fromEntries(Object.entries(SHAPES).map(([n, [size]]) => [n, jpg('green', size.split('x').map((x) => x * 4).join('x'))]));
+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('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => 'https://media.test/' + n + '.webm';
+const post = (n, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'dim ' + SHAPES[n][0]]], content: 'clip ' + n + ' ' + U(n) }, SK);
+const seeded = ['tall1', 'wide', 'tall2', 'tall3', 'sq'].map((n, i) => post(n, 100 + i * 100));
+
+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.setRequestInterception(true);
+const asked = [];
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.pathname === '/vmedia/first.jpg') {
+ const name = /\/(\w+)\.webm$/.exec(u.searchParams.get('u') || '')?.[1];
+ asked.push('first:' + name);
+ const go = () => r.respond({ status: 200, contentType: 'image/jpeg', body: FIRST[name], headers: { 'access-control-allow-origin': '*' } });
+ return SHAPES[name][1] ? void setTimeout(go, SHAPES[name][1]) : go();
+ }
+ if (u.pathname.startsWith('/vmedia/')) return r.respond({ status: 404, body: 'no' });
+ if (u.host === 'media.test') {
+ const name = /\/(\w+)\.webm$/.exec(u.pathname)[1], f = CLIPS[name];
+ asked.push('clip:' + name);
+ const range = /^bytes=(\d+)-(\d*)$/.exec(r.headers().range || '');
+ const cors = { 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'access-control-expose-headers': 'content-range, content-length' };
+ if (!range) return r.respond({ status: 200, contentType: 'video/webm', body: f, headers: cors });
+ const a = +range[1], b = range[2] ? Math.min(+range[2], f.length - 1) : f.length - 1;
+ return r.respond({ status: 206, contentType: 'video/webm', body: f.subarray(a, b + 1), headers: { ...cors, 'content-range': 'bytes ' + a + '-' + b + '/' + f.length } });
+ }
+ r.continue();
+});
+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 groups = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group')].map((g) => { const s = g.querySelector('.mp-slide'), v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop().replace('.webm', ''), wait: g.classList.contains('mp-wait'), off: g.classList.contains('mp-off'), cur: s.classList.contains('mp-cur'), top: Math.round(r.top), poster: !!v.getAttribute('poster'), playing: !v.paused }; }));
+const curClip = async () => (await groups()).find((g) => g.cur)?.clip;
+const inCarousel = async () => (await groups()).filter((g) => !g.wait && !g.off).map((g) => g.clip).join(' ');
+const counter = () => page.evaluate(() => document.querySelector('.mp-position').textContent);
+
+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: 'Clip 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());
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000));
+
+ console.log('\n[the Videos grid: the clips the screen\'s way round]');
+ await click('videos');
+ check('the grid shows the tall clips and the square one, not the wide one', await waitFor(() => { const t = [...document.querySelectorAll('.media-tile.video')].map((x) => x.getAttribute('data-key')); return t.length === 4 && !t.some((k) => /wide/.test(k)); }, null, 8000), String(await page.evaluate(() => document.querySelectorAll('.media-tile.video').length)));
+ check('…with a toggle over it saying so', (await page.evaluate(() => document.querySelector('.media-fit-btn')?.textContent)) === 'Tall only', await page.evaluate(() => document.querySelector('.media-fit-btn')?.textContent));
+ await page.evaluate(() => document.querySelector('.media-fit-btn').click());
+ check('tapped: all five, the wide one too', await waitFor(() => document.querySelectorAll('.media-tile.video').length === 5, null, 4000));
+ check('…and the toggle says all shapes', (await page.evaluate(() => document.querySelector('.media-fit-btn')?.textContent)) === 'All shapes');
+ await page.evaluate(() => document.querySelector('.media-fit-btn').click());
+ check('tapped again: back to the four', await waitFor(() => document.querySelectorAll('.media-tile.video').length === 4, null, 4000));
+
+ console.log('\n[the carousel: a post joins once its clip is ready to play]');
+ await page.evaluate(() => [...document.querySelectorAll('.media-tile.video')].find((x) => /tall1/.test(x.getAttribute('data-key'))).click());
+ check('the pager opens on the clip tapped', await waitFor(() => { const s = document.querySelector('.media-pager.videos .mp-slide.mp-cur'); return !!s && /tall1/.test(s.getAttribute('data-k')); }, null, 6000));
+ check('…and it plays', await waitFor(() => !document.querySelector('.mp-slide.mp-cur video').paused, null, 8000));
+ check('the wide clip is not in this carousel at all (the grid\'s list is the screen\'s way round)', !(await groups()).some((g) => g.clip === 'wide'), JSON.stringify(await groups()));
+ check('the next tall clip, its first frame still on its way, waits out of the carousel', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall2/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && g.classList.contains('mp-wait'); }, null, 1000), await inCarousel());
+ check('the one after, ready, has joined', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall3/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-wait') && !!g.querySelector('video').getAttribute('poster'); }, null, 8000), await inCarousel());
+ const before = await inCarousel();
+ await page.keyboard.press('ArrowDown');
+ check('a step down lands on it, past the one not ready', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall3/.test(s.getAttribute('data-k')); }, null, 4000), (await curClip()) + ' (carousel was: ' + before + ')');
+ check('…never a black slide: the clip landed on had its frame', (await groups()).find((g) => g.clip === 'tall3')?.poster === true);
+ const n0 = await counter();
+ check('the waiting post joins once its frame comes', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall2/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-wait'); }, null, 8000), await inCarousel());
+ await sleep(300);
+ const g = await groups();
+ check('…joining above the clip on screen without moving it', g.find((x) => x.cur)?.clip === 'tall3' && Math.abs(g.find((x) => x.cur).top) <= 1, JSON.stringify(g.map((x) => x.clip + ':' + x.top + (x.cur ? '*' : ''))));
+ check('…and the counter grew', (await counter()) !== n0, n0 + ' → ' + (await counter()));
+ await page.keyboard.press('ArrowUp');
+ check('a step back up lands on the one that joined', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall2/.test(s.getAttribute('data-k')) && !s.querySelector('video').paused; }, null, 6000), String(await curClip()));
+
+ console.log('\n[the toggle in the pager]');
+ check('the pager\'s toggle says tall only', (await page.evaluate(() => document.querySelector('.mp-fit')?.textContent)) === 'Tall only');
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped: the wide clip\'s post is built (it joins once ready)', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /wide/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-off'); }, null, 4000), await inCarousel());
+ check('…the clip on screen stayed put', (await curClip()) === 'tall2' && Math.abs((await groups()).find((x) => x.cur).top) <= 1, JSON.stringify((await groups()).map((x) => x.clip + ':' + x.top + (x.cur ? '*' : ''))));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped again: kept out for its shape', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /wide/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && g.classList.contains('mp-off'); }, null, 2000));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ posts join the carousel ready to play, the screen\'s way round' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-tap-slop-test.js b/tools/pager-tap-slop-test.js
index 09f761a..4de87df 100644
--- a/tools/pager-tap-slop-test.js
+++ b/tools/pager-tap-slop-test.js
@@ -76,6 +76,7 @@ try {
localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ localStorage.setItem('coinos-clips-fit', 'all'); // (the wide clip stays in: this test is about the finger, not the shapes)
}, [base, AUTHOR, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);