aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-23 23:00:31 -0700
committerAdam Soltys <[email protected]>2026-09-23 23:00:31 -0700
commitf6fd9f998831330e3a8819e294cdd8d15c1a87f6 (patch)
tree3d8399d75efffc9c9bd091280984c8776682cd05 /tools
parent75679fdd5d24d36e3c54316e88ccefcf954afc82 (diff)
downloadcoinosv3-f6fd9f998831330e3a8819e294cdd8d15c1a87f6.tar.gz
coinosv3-f6fd9f998831330e3a8819e294cdd8d15c1a87f6.zip
Videos play by themselves, muted, while on screen — with an Unmute button; YouTube too.
An inline video starts as it scrolls into view (muted, the only way a browser lets a page start one), pauses as it leaves, and a corner button turns the sound on; the native controls' own mute keeps the button honest. A YouTube box loads its player muted once in view (enablejsapi, so the same button can unmute it over the iframe API) and pauses out of view; a tap on the poster still means sound. One IntersectionObserver watches every player. Data Saver keeps tap-to-play. Tests adapted to the feed's new admission rule (a row waits for its author's face and pictures): the markdown and post-actions tests seed the poster's profile and serve their pictures locally; a picture that cannot be decoded is now a link, by design. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
-rw-r--r--tools/markdown-note-test.js19
-rw-r--r--tools/post-actions-test.js5
-rw-r--r--tools/video-autoplay-test.js96
3 files changed, 115 insertions, 5 deletions
diff --git a/tools/markdown-note-test.js b/tools/markdown-note-test.js
index 2c5a539..e27336f 100644
--- a/tools/markdown-note-test.js
+++ b/tools/markdown-note-test.js
@@ -21,8 +21,11 @@ const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? '
const SK = generateSecretKey();
const AUTHOR = getPublicKey(SK);
-const IMG = 'https://coinos.io/punks/7.webp';
-const NOEXT = 'https://example.invalid/media/9f2a1c';
+// served below: a picture the feed can actually decode — a row shows a
+// picture it has decoded and links the ones it could not
+const IMG = 'http://localhost:5298/pic.png';
+const NOEXT = 'http://localhost:5298/media/9f2a1c';
+const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
// the shape the real post had: the paren, then a newline, then the URL
const CASES = [
['md-image', 'here it is\n![cover](' + IMG + ')\nthat was it'],
@@ -36,7 +39,12 @@ const notes = CASES.map(([, content], i) =>
finalizeEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000) - i * 60, tags: [], content }, SK));
const html = await buildHtml({ minify: true, pwa: false });
-const server = Bun.serve({ port: 5298, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const server = Bun.serve({ port: 5298, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); // the author's face, so rows are admitted at once
+ if (path === '/pic.png' || path === '/media/9f2a1c') return new Response(PNG, { headers: { 'content-type': 'image/png' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
const errs = [];
@@ -60,13 +68,16 @@ try {
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: 'Markdown Poster', t: Date.now() } }));
}, [cacheKeyFor(mn + '\n'), AUTHOR, notes]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
await sleep(1000);
await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
- await sleep(4000);
+ // rows are admitted once their pictures have been tried (a dead host takes the deadline)
+ for (let i = 0; i < 60; i++) { if ((await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length)) >= 6) break; await sleep(250); }
+ await sleep(500);
const rows = await page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row')].map((r) => {
const b = r.querySelector('.note-text');
diff --git a/tools/post-actions-test.js b/tools/post-actions-test.js
index b7d56e6..d8c4c84 100644
--- a/tools/post-actions-test.js
+++ b/tools/post-actions-test.js
@@ -52,13 +52,16 @@ try {
localStorage.setItem(k + ':feedNotes', JSON.stringify([
{ id: nid, pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'More flowers http://localhost:5258/pic.png', tags: [] },
]));
+ // a known face: a row is admitted once its author's picture has been tried
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Flower Poster', t: Date.now() } }));
}, [base, AUTHOR, NOTE_ID]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
await sleep(1000);
await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
- await sleep(2500);
+ for (let i = 0; i < 48; i++) { if (await page.$('.note-act')) break; await sleep(250); }
+ await sleep(600);
const acts = await page.evaluate(() => [...document.querySelectorAll('.note-act')].map((b) => b.getAttribute('aria-label')));
check('every action sits under the post', acts.slice(0, 4).join(',') === 'Reply,Boost,Quote,Like', acts.join(',') || 'none');
diff --git a/tools/video-autoplay-test.js b/tools/video-autoplay-test.js
new file mode 100644
index 0000000..2665ac5
--- /dev/null
+++ b/tools/video-autoplay-test.js
@@ -0,0 +1,96 @@
+// Autoplay: a video in a post plays by itself, muted, while it is on screen,
+// with a corner Unmute button; it pauses once scrolled away. A YouTube box
+// loads its player muted when in view (with the same button) — nothing is
+// loaded for a box that never comes into view. Set CLIP to a webm to serve;
+// the default is made with ffmpeg on the fly.
+// Run: bun tools/video-autoplay-test.js
+import puppeteer from 'puppeteer-core';
+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 SK = generateSecretKey();
+const 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 CLIP = process.env.CLIP || '/tmp/coinos-autoplay-clip.webm';
+if (!existsSync(CLIP)) {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=2', '-t', '2', '-c:v', 'libvpx', '-b:v', '50k', '-c:a', 'libvorbis', CLIP]);
+ if (p.exitCode !== 0) { console.log('ffmpeg failed; set CLIP=<a webm>'); process.exit(1); }
+}
+const seeded = [
+ post('watch this http://localhost:5301/clip.webm', 100),
+ ...Array.from({ length: 6 }, (_, i) => post('filler post number ' + (i + 1) + ' to push the next one down the page ' + 'lorem ipsum '.repeat(40), 200 + i * 10)),
+ post('and this one https://youtu.be/dQw4w9WgXcQ', 300),
+];
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5301, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/clip.webm') return new Response(Bun.file(CLIP), { headers: { 'content-type': 'video/webm' } });
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+// YouTube itself is never contacted: what matters is what the box asks for
+await page.setRequestInterception(true);
+const ytRequests = [];
+page.on('request', (r) => { if (/youtube|ytimg/.test(r.url())) { ytRequests.push(r.url()); r.abort(); } else r.continue(); });
+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; };
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5301/', { 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() } }));
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(800);
+ await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'Feed'); if (e) e.click(); });
+
+ const players = () => ytRequests.filter((u) => /youtube-nocookie\.com\/embed/.test(u)).length; // the poster still comes from ytimg, as before
+ console.log('\n[a video in a post]');
+ check('the post shows its video in a box with a sound button', await waitFor(() => !!document.querySelector('.note-video-box video') && !!document.querySelector('.note-video-box .vid-sound'), 20000));
+ check('it is muted, and the button offers Unmute', await page.evaluate(() => { const v = document.querySelector('.note-video-box video'); const b = document.querySelector('.note-video-box .vid-sound'); return v.muted && /unmute/i.test(b.textContent); }));
+ check('on screen, it plays by itself', await waitFor(() => { const v = document.querySelector('.note-video-box video'); return v && !v.paused && v.currentTime > 0; }, 8000),
+ JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.note-video-box video'); return v && { paused: v.paused, t: v.currentTime, err: v.error && v.error.code, ready: v.readyState }; })));
+ await page.evaluate(() => document.querySelector('.note-video-box .vid-sound').click()); await sleep(200);
+ check('Unmute turns the sound on and offers Mute', await page.evaluate(() => { const v = document.querySelector('.note-video-box video'); const b = document.querySelector('.note-video-box .vid-sound'); return !v.muted && /^\S+ mute$/i.test(b.textContent.trim()); }));
+ await page.evaluate(() => window.scrollTo(0, 1000)); await sleep(700);
+ check('scrolled away, it pauses', await waitFor(() => { const v = document.querySelector('.note-video-box video'); return v && v.paused; }, 4000));
+
+ console.log('\n[a YouTube link]');
+ const ytBefore = players();
+ check('a box further down has not loaded a player', ytBefore === 0 && await page.evaluate(() => { const b = document.querySelector('.yt-embed'); return b && b.getBoundingClientRect().top > window.innerHeight; }), String(ytBefore));
+ check('a box stands in for the player', await page.evaluate(() => !!document.querySelector('.yt-embed')));
+ await page.evaluate(() => document.querySelector('.yt-embed').scrollIntoView({ block: 'center' })); await sleep(900);
+ const src = await waitFor(() => !!document.querySelector('.yt-embed iframe'), 6000) ? await page.evaluate(() => document.querySelector('.yt-embed iframe').src) : '';
+ check('in view, the player loads muted with autoplay and the API on', /youtube-nocookie\.com\/embed\/dQw4w9WgXcQ/.test(src) && /autoplay=1/.test(src) && /mute=1/.test(src) && /enablejsapi=1/.test(src) && /origin=/.test(src), src);
+ check('...with the sound button over it', await page.evaluate(() => !!document.querySelector('.yt-embed .vid-sound')));
+ await sleep(500);
+ check('...and only then was a player asked for', ytBefore === 0 && players() > 0, ytBefore + ' → ' + players());
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ videos play by themselves, muted, while on screen' : '\n❌ failed');
+process.exit(ok ? 0 : 1);