aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/call-test.js188
-rw-r--r--tools/call-wake-test.js214
-rw-r--r--tools/composer-media-test.js21
-rw-r--r--tools/feed-media-views-test.js2
-rw-r--r--tools/feed-momentum-test.js48
-rw-r--r--tools/feed-new-posts-test.js20
-rw-r--r--tools/go-live-test.js150
-rw-r--r--tools/hls-upload-test.js92
-rw-r--r--tools/live-stream-test.js197
-rw-r--r--tools/media-servers-test.js52
-rw-r--r--tools/pager-swipe-fly-test.js61
-rw-r--r--tools/profile-pager-test.js15
-rw-r--r--tools/quoted-clip-fullscreen-test.js90
-rw-r--r--tools/video-fallback-test.js6
-rw-r--r--tools/video-hls-play-test.js133
-rw-r--r--tools/vmedia-hls-test.js84
16 files changed, 1293 insertions, 80 deletions
diff --git a/tools/call-test.js b/tools/call-test.js
new file mode 100644
index 0000000..5bfa38c
--- /dev/null
+++ b/tools/call-test.js
@@ -0,0 +1,188 @@
+// 1:1 calls (NIP-AC): two wallets, each in its own browser tab, a relay
+// between them. The caller's πŸ“ž / πŸŽ₯ in their conversation rings the other
+// over whatever page they're on; Answer connects them β€” each hears and sees
+// the other β€” mute and camera-off reach the other side, hang-up ends it on
+// both. A call can be declined, and a stranger's (neither followed nor
+// written to) doesn't ring, but is in the conversation as a missed call. Everything on the wire is an ephemeral gift wrap.
+// Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { getPublicKey } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+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 PORT = 5305;
+const SLOW = +(process.env.SLOW || 0);
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+
+// a small relay: keeps what isn't ephemeral, sends each event to the subscriptions it matches
+const stored = [], subs = new Set(), kindsSeen = [], wrapIds = new Set();
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
+ && (!f.since || e.created_at >= f.since)
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+const server = Bun.serve({ port: PORT,
+ fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ // two tabs on one machine: no TURN needed (SLOW=ms makes it, and the relay
+ // lookups, take as long as they do out there)
+ if (path === '/live/turn') { if (SLOW) await sleep(SLOW); return Response.json({ iceServers: [], ttl: 600 }); }
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+ },
+ websocket: {
+ open(ws) { ws.data = { subs: new Map() }; },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') {
+ const e = m[1]; kindsSeen.push(e.kind); if (e.kind === 21059) wrapIds.add(e.id);
+ ws.send(JSON.stringify(['OK', e.id, true, '']));
+ if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
+ for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match({ ...f, since: undefined }, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
+ } else if (m[0] === 'REQ') {
+ if (SLOW && m.slice(2).some((f) => (f.kinds || []).includes(10050))) { setTimeout(() => ws.send(JSON.stringify(['EOSE', m[1]])), SLOW); return; }
+ const s = { ws, id: m[1], filters: m.slice(2) };
+ ws.data.subs.set(m[1], s); subs.add(s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') { const s = ws.data.subs.get(m[1]); if (s) subs.delete(s); }
+ },
+ close(ws) { for (const s of ws.data.subs.values()) subs.delete(s); },
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+// our DM relays lead to the test's relay
+const toLocal = (port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+async function wallet(name) {
+ const page = await (await browser.createBrowserContext()).newPage(); // a browser of its own: its own storage
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(toLocal, PORT);
+ page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e)));
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
+ const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+ await page.goto(`http://localhost:${PORT}/`, { 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');
+ return { page, mn, pk: getPublicKey(privateKeyFromSeedWords(mn)), base: cacheKeyFor(mn + '\n'), click, waitText, name };
+}
+const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const follow = (w, pks, profiles) => w.page.evaluate(([k, pks, profiles]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
+}, [w.base, pks, profiles]);
+// the call's pictures and sound actually moving: frames decoded, bytes in
+const media = (page) => page.evaluate(async () => {
+ const v = document.querySelector('video.call-remote'), a = document.querySelector('audio.call-audio');
+ const s = a && a.srcObject;
+ return { video: !!v && v.videoWidth > 0 && !v.paused, audio: !!s && s.getAudioTracks().some((t) => t.readyState === 'live'), local: !!document.querySelector('video.call-local')?.videoWidth };
+});
+
+try {
+ const A = await wallet('alice'), B = await wallet('bob'), C = await wallet('carol');
+ const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() }, [C.pk]: { name: 'Carol Stranger', t: Date.now() } };
+ await follow(A, [B.pk], profiles); await follow(B, [A.pk], profiles); await follow(C, [B.pk], profiles);
+ for (const w of [A, B, C]) { await w.page.reload({ waitUntil: 'domcontentloaded' }); await w.waitText('receive'); }
+ const openDm = async (w, peer) => {
+ await w.page.goto(`http://localhost:${PORT}/` + npubEncode(peer), { waitUntil: 'domcontentloaded' });
+ await w.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await w.page.evaluate(() => { const b = [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)); b.click(); });
+ await w.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
+ };
+
+ console.log('[a video call]');
+ await openDm(A, B.pk);
+ check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2));
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
+ check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000));
+ check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000));
+ check('everything went as ephemeral gift wraps', kindsSeen.length > 0 && !kindsSeen.some((k) => k >= 25050 && k <= 25055) && kindsSeen.includes(21059));
+ if (process.env.RING_WAIT) await sleep(+process.env.RING_WAIT); // a person reaching for the phone
+ const tAccept = Date.now();
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
+ const answerMs = Date.now() - tAccept;
+ check('answered: both connected, the clock running (' + answerMs + ' ms from Answer)', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
+ await sleep(1500);
+ const ma = await media(A.page), mb = await media(B.page);
+ check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb }));
+ await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click());
+ check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click());
+ check('minimized: a bar across the top, the page usable under it', await waitFor(A.page, () => !!document.querySelector('.call-min-bar') && !document.querySelector('.call-bar'), 3000));
+ await A.page.evaluate(() => document.querySelector('.call-min-bar').click());
+ await waitFor(A.page, () => !!document.querySelector('.call-hang'), 3000);
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ check('hanging up ends it here', await waitFor(A.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 3000));
+ check('...and there', await waitFor(B.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
+ check('...the cameras let go', await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000) && await waitFor(B.page, () => !document.querySelector('.call-overlay'), 6000));
+ const logs = (page) => page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText.replace(/\s+/g, ' ').trim()));
+ check('the call is in the caller\'s conversation: outgoing, how long', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing video call Β· \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(A.page)));
+ await openDm(B, A.pk);
+ check('...and in the callee\'s: incoming', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Incoming video call Β· \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(B.page)));
+
+ console.log('\n[declined]');
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ check('a voice call rings', await waitFor(B.page, () => /voice call/i.test(document.querySelector('.call-overlay')?.innerText || ''), 15000));
+ await B.page.evaluate(() => document.querySelector('.call-decline').click());
+ check('declined: the caller is told', await waitFor(A.page, () => /declined/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000);
+ check('...and it\'s logged so on both sides', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing voice call Β· Declined/.test(r.innerText)), 5000)
+ && await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Declined voice call/.test(r.innerText)), 5000), JSON.stringify([await logs(A.page), await logs(B.page)]));
+ await B.page.evaluate(() => document.querySelector('.chat-row.call-log .call-log-pill').click());
+ check('a tap on a logged call calls back', await waitFor(A.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000));
+ await A.page.evaluate(() => document.querySelector('.call-decline').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+
+ console.log('\n[a remote signer\'s way: the candidates inside the offer and answer]');
+ for (const w of [A, B]) await w.page.evaluate(() => localStorage.setItem('coinos-call-trickle', '0'));
+ const before = wrapIds.size;
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-accept'), 20000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ check('it connects', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000));
+ await sleep(1500);
+ const m2 = await media(A.page);
+ check('...picture and sound', m2.video && m2.audio, JSON.stringify(m2));
+ const sent = wrapIds.size - before;
+ check('...with a handful of messages, not one per candidate (offer, answer, its copy for my other devices)', sent <= 3, sent + ' wraps');
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);
+ for (const w of [A, B]) await w.page.evaluate(() => localStorage.removeItem('coinos-call-trickle'));
+
+ console.log('\n[a stranger]');
+ await openDm(C, B.pk);
+ await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
+ await sleep(5000);
+ check('someone Bob neither follows nor wrote to doesn\'t ring him', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
+ await waitFor(C.page, () => !document.querySelector('.call-overlay'), 6000);
+ await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
+ await sleep(4000);
+ check('...nor does their next call (a logged call isn\'t having written to them)', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
+ await openDm(B, C.pk);
+ check('...but it\'s in his conversation with them, as a call that didn\'t ring, and why', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText) && /people you follow or have written to/i.test(r.innerText)), 8000),
+ JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText))));
+ check('...the caller who gave up has it as cancelled', await waitFor(C.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Cancelled voice call/.test(r.innerText)), 6000));
+ for (const w of [A, B, C]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\nβœ… calls: rung, answered, seen and heard, ended' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js
new file mode 100644
index 0000000..54c42ee
--- /dev/null
+++ b/tools/call-wake-test.js
@@ -0,0 +1,214 @@
+// A call wakes a closed app: with Bob's coinos closed, Alice calls; the push
+// the notifier would send (delivered here to Bob's service worker through
+// Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open
+// it with Bob's key and ring β€” "Alice Caller is calling" β€” and the tap that
+// opens the app finds the offer kept for it: it rings there, Bob answers,
+// they talk. A push for a stranger's call, or for something that isn't an
+// offer, rings nothing. Plus the worker's check on its own (classifyCall).
+// Run: bun tools/call-wake-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { bytesToHex } from '@noble/hashes/utils';
+import { npubEncode } from 'nostr-tools/nip19';
+import { classifyCall } from '../src/dm-inbox.js';
+import { callEvent, wrapCall, CALL } from '../src/calls.js';
+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 PORT = 5306;
+
+console.log('[the worker\'s check]');
+{
+ const bob = generateSecretKey(), alice = generateSecretKey(), eve = generateSecretKey();
+ const B = getPublicKey(bob), Al = getPublicKey(alice), E = getPublicKey(eve);
+ const box = [{ pubkey: B, sk: bytesToHex(bob), follows: [Al], known: [], muted: [], names: { [Al]: 'Alice' } }];
+ const offer = (sk, ago = 0, kind = CALL.OFFER, to = B) => wrapCall(finalizeEvent({ ...callEvent(kind, to, 'id-1', 'v=0', kind === CALL.OFFER ? [['call-type', 'video']] : []), created_at: Math.floor(Date.now() / 1000) - ago }, sk), to);
+ const r = await classifyCall(offer(alice), box);
+ check('a fresh offer from someone followed rings, named, video', !!r && !r.quiet && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al);
+ check('...a stranger\'s doesn\'t ring, but is kept as quiet', (await classifyCall(offer(eve), box))?.quiet === true);
+ check('...nor someone muted', (await classifyCall(offer(alice), [{ ...box[0], muted: [Al] }])) === null);
+ check('...but someone written to rings', (await classifyCall(offer(eve), [{ ...box[0], rings: [E] }]))?.quiet === false);
+ check('...not someone who only wrote to us', (await classifyCall(offer(eve), [{ ...box[0], known: [E], rings: [] }]))?.quiet === true);
+ check('...an offer over a minute old doesn\'t', (await classifyCall(offer(alice, 90), box)) === null);
+ check('...nor a candidate or a hang-up', (await classifyCall(offer(alice, 0, CALL.ICE), box)) === null && (await classifyCall(offer(alice, 0, CALL.HANGUP), box)) === null);
+ check('...nor a wrap for someone else', (await classifyCall(offer(alice, 0, CALL.OFFER, E), box)) === null);
+}
+
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const stored = [], subs = new Set(), callWraps = new Map();
+const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+const server = Bun.serve({ port: PORT,
+ fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
+ if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 });
+ const w = /^\/nwcpush\/wrap\/([0-9a-f]{64})$/.exec(path); // the notifier's keeping of call wraps
+ if (w) return callWraps.has(w[1]) ? Response.json(callWraps.get(w[1])) : Response.json({ error: 'gone' }, { status: 404 });
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+ },
+ websocket: {
+ open(ws) { ws.data = new Map(); },
+ message(ws, raw) {
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m[0] === 'EVENT') {
+ const e = m[1];
+ ws.send(JSON.stringify(['OK', e.id, true, '']));
+ if (e.kind === 21059) callWraps.set(e.id, e); else if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
+ for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
+ } else if (m[0] === 'REQ') {
+ const s = { ws, id: m[1], filters: m.slice(2) };
+ ws.data.set(m[1], s); subs.add(s);
+ for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
+ ws.send(JSON.stringify(['EOSE', m[1]]));
+ } else if (m[0] === 'CLOSE') subs.delete(ws.data.get(m[1]));
+ },
+ close(ws) { for (const s of ws.data.values()) subs.delete(s); },
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+const toLocal = (port) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+async function wallet(name) {
+ const context = await browser.createBrowserContext();
+ await context.overridePermissions(`http://localhost:${PORT}`, ['notifications']);
+ const page = await context.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(toLocal, PORT);
+ page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e)));
+ const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
+ const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+ await page.goto(`http://localhost:${PORT}/`, { 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');
+ const sk = privateKeyFromSeedWords(mn);
+ return { context, page, mn, sk, pk: getPublicKey(sk), base: cacheKeyFor(mn + '\n'), click, waitText, name };
+}
+const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+// the push the notifier sends, delivered to a context's service worker
+async function push(w, data) {
+ const cdp = await w.page.target().createCDPSession();
+ const regs = [];
+ cdp.on('ServiceWorker.workerRegistrationUpdated', (e) => regs.push(...e.registrations));
+ await cdp.send('ServiceWorker.enable');
+ for (let i = 0; i < 25 && !regs.some((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted); i++) await sleep(200);
+ const reg = regs.find((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted);
+ await cdp.send('ServiceWorker.deliverPushMessage', { origin: `http://localhost:${PORT}`, registrationId: reg.registrationId, data: JSON.stringify(data) });
+ await cdp.detach();
+}
+const notifications = (page) => page.evaluate(async () => (await (await navigator.serviceWorker.ready).getNotifications()).map((n) => n.title + ' | ' + n.body + ' | ' + n.tag));
+
+try {
+ console.log('\n[a closed app, rung]');
+ const A = await wallet('alice'), B = await wallet('bob');
+ const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() } };
+ // their follow lists, where the app (and Bob's worker mirror) read them
+ for (const [w, other] of [[A, B], [B, A]]) {
+ // (newer than the starter list a new wallet publishes for itself)
+ stored.push(finalizeEvent({ kind: 3, created_at: Math.floor(Date.now() / 1000) + 120, content: '', tags: [['p', other.pk]] }, w.sk));
+ await w.page.evaluate(([k, pk, profiles]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
+ }, [w.base, other.pk, profiles]);
+ await w.page.reload({ waitUntil: 'domcontentloaded' });
+ await w.waitText('receive');
+ }
+ check('Bob\'s app has given his worker what it needs: his key, his follows', await waitFor(B.page, (pk) => new Promise((res) => {
+ const r = indexedDB.open('coinos-dm-inbox');
+ r.onsuccess = () => { try { const g = r.result.transaction('inbox').objectStore('inbox').getAll(); g.onsuccess = () => res(g.result.some((x) => x.sk && (x.follows || []).includes(pk))); g.onerror = () => res(false); } catch { res(false); } };
+ r.onerror = () => res(false);
+ }), 20000, A.pk));
+ await B.page.evaluate(() => navigator.serviceWorker.ready);
+ // Bob closes coinos
+ await B.page.goto('about:blank');
+ // Alice calls
+ await A.page.goto(`http://localhost:${PORT}/` + npubEncode(B.pk), { waitUntil: 'domcontentloaded' });
+ await A.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await A.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ await A.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
+ const wrapsBefore = new Set(callWraps.keys());
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ let offerWrap = null;
+ for (let i = 0; i < 50 && !offerWrap; i++) { await sleep(200); offerWrap = [...callWraps.values()].find((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); }
+ check('Alice\'s offer goes out, big enough for the notifier to wake Bob for', !!offerWrap, offerWrap && offerWrap.content.length + ' chars');
+ const small = [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.content.length < 2000);
+ check('...and what follows it is small enough not to', small.length >= 1 && small.every((w) => w.content.length < 1500), small.map((w) => w.content.length).join(','));
+ await push(B, { type: 'notify', reason: 'call', wrapId: offerWrap.id });
+ await sleep(1500);
+ // the tap: the worker opens the app at ?open=call
+ await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' });
+ const ns = await notifications(B.page);
+ check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns));
+ check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000));
+ // Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one)
+ const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length;
+ const ringingCands = toBob();
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ check('Bob answers: they\'re connected', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000)
+ && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
+ check('Alice sends her candidates again once answered (Bob\'s app heard none of the first ones)', toBob() >= 2 * ringingCands && ringingCands > 0, ringingCands + ' β†’ ' + toBob());
+ check('...and hear each other', await waitFor(B.page, () => { const a = document.querySelector('audio.call-audio'); return !!a && !!a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live'); }, 5000));
+ check('the "is calling" notification is gone once answered', await waitFor(B.page, async () => !(await (await navigator.serviceWorker.ready).getNotifications({ tag: 'call' })).length, 5000));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);
+
+ console.log('\n[pushes that ring nothing]');
+ await B.page.goto('about:blank');
+ const eve = generateSecretKey();
+ const strangers = wrapCall(finalizeEvent(callEvent(CALL.OFFER, B.pk, 'x-1', 'v=0\r\n' + 'a'.repeat(2400), [['call-type', 'voice']]), eve), B.pk);
+ callWraps.set(strangers.id, strangers);
+ await push(B, { type: 'notify', reason: 'call', wrapId: strangers.id });
+ await push(B, { type: 'notify', reason: 'call', wrapId: 'f'.repeat(64) }); // one the notifier no longer has
+ await sleep(1500);
+ await B.page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await B.waitText('receive');
+ check('a stranger\'s offer, or one gone, rings nothing', !(await notifications(B.page)).some((n) => /calling/.test(n)) && !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await B.page.goto(`http://localhost:${PORT}/` + npubEncode(getPublicKey(eve)), { waitUntil: 'domcontentloaded' });
+ await B.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ check('...but the stranger\'s call is in the app once it opens: missed, didn\'t ring', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /didn.t ring/i.test(r.innerText)), 10000));
+
+ console.log('\n[rung while closed, and nobody came]');
+ await B.page.goto('about:blank');
+ const before2 = new Set(callWraps.keys());
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ let offer2 = null;
+ for (let i = 0; i < 50 && !offer2; i++) { await sleep(200); offer2 = [...callWraps.values()].find((w) => !before2.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); }
+ await push(B, { type: 'notify', reason: 'call', wrapId: offer2.id });
+ await sleep(1500);
+ await A.page.evaluate(() => document.querySelector('.call-hang').click()); // Alice gives up
+ // Bob opens coinos later on, past the call's minute (the kept offer dated back)
+ const page2 = await B.context.newPage();
+ await page2.goto(`http://localhost:${PORT}/icon-192.png`, { waitUntil: 'domcontentloaded' }); // the origin, without the app (which would take the offer)
+ await page2.evaluate(async () => {
+ const c = await caches.open('coinos-call'), r = await c.match('/__pending-call');
+ const j = await r.json(); j.at -= 120_000;
+ await c.put('/__pending-call', new Response(JSON.stringify(j), { headers: { 'content-type': 'application/json' } }));
+ });
+ await page2.close();
+ await B.page.goto(`http://localhost:${PORT}/` + npubEncode(A.pk), { waitUntil: 'domcontentloaded' });
+ check('the app doesn\'t ring for a call long over', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
+ await B.page.waitForSelector('.prof-actions', { timeout: 20000 });
+ await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
+ check('...it\'s a missed call in the conversation', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText)), 10000),
+ JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText))));
+ for (const w of [A, B]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\nβœ… a call wakes a closed app' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js
index 512b8bd..94b343a 100644
--- a/tools/composer-media-test.js
+++ b/tools/composer-media-test.js
@@ -18,7 +18,7 @@ const page = await browser.newPage();
const uploads = [];
await page.setRequestInterception(true);
// every public media host the upload may try first (nothing leaves the machine)
-const HOSTS = /blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/;
+const HOSTS = /media\.example|blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/;
page.on('request', (req) => {
const u = req.url();
if (HOSTS.test(u) && req.method() === 'PUT') {
@@ -46,6 +46,23 @@ try {
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('button[aria-label="Settings"]').click());
+ await sleep(300);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find(e => e.textContent.includes('Nostr')).click());
+ await page.waitForSelector('[data-key="media-servers"] textarea');
+ const setServers = async (value) => page.evaluate(value => {
+ const card = document.querySelector('[data-key="media-servers"]');
+ const input = card.querySelector('textarea'); input.value = value;
+ input.dispatchEvent(new Event('input', {bubbles:true}));
+ card.querySelector('button').click();
+ }, value);
+ await setServers('https://media.example/');
+ check('settings save a normalized custom server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]'));
+ await setServers('https://media.example/path');
+ check('invalid settings preserve the previous server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]'));
+ await page.evaluate(() => document.querySelectorAll('[data-key="media-servers"] button')[1].click());
+ check('Use default clears the override', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === null));
+ await setServers('https://media.example');
const base = cacheKeyFor(mn + '\n');
const A = 'a'.repeat(63) + '9';
await page.evaluate(([k, a]) => {
@@ -91,7 +108,7 @@ try {
document.querySelector('.thread-reply-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
});
const landed = await waitFor(() => /nostr\.download\/f{64}\.gif/.test(document.querySelector('.thread-reply-input')?.value || ''), 15000);
- check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1,
+ check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1 && uploads[0] === 'https://media.example/upload',
JSON.stringify(await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)) + ' uploads=' + uploads.length);
check('...and the preview shows it', await waitFor(() => !!document.querySelector('.draft-preview img, .draft-preview .note-img, .draft-preview a'), 5000));
// the upload re-rendered the page: the field must still be editable
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index fe62413..58e942b 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -135,6 +135,7 @@ try {
}), await page.evaluate(() => 'page ' + document.documentElement.scrollWidth + ' of ' + innerWidth));
console.log('\n[Images: a grid]');
+ await page.evaluate(() => localStorage.setItem('coinos-clips-fit', 'all')); // every shape: these pictures are wide (the Tall only filter is Videos' to show)
await mode('images');
check('the feed becomes a grid of its pictures, one tile each', await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 3, 8000),
String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile').length)));
@@ -161,6 +162,7 @@ try {
check('Back closes the pager, leaving the grid', await page.evaluate(() => !document.querySelector('.media-pager') && !!document.querySelector('.media-grid')));
check('...and the page scrolls again', await page.evaluate(() => !document.documentElement.classList.contains('no-scroll')));
+ await page.evaluate(() => localStorage.removeItem('coinos-clips-fit')); // the screen's way round again
console.log('\n[the big clip in the feed]');
await mode('all'); await sleep(800);
await page.evaluate(() => { const v = [...document.querySelectorAll('.note-video-box video')].find((x) => /c\.mp4/.test(x.getAttribute('src') || x.getAttribute('data-lazy-src') || '')); v.scrollIntoView({ block: 'center' }); });
diff --git a/tools/feed-momentum-test.js b/tools/feed-momentum-test.js
index 29e4934..3debd8a 100644
--- a/tools/feed-momentum-test.js
+++ b/tools/feed-momentum-test.js
@@ -1,8 +1,7 @@
// Desktop feed scrolling feels like a phone: a mouse-wheel notch coasts
// farther than the browser's own step, quick notches stack, a reverse turn
// brakes; a trackpad's smooth deltas are left to the browser; a mouse drag
-// scrolls and a release while moving throws it; a drag that moved doesn't
-// click; a sideways drag is left alone (text selection).
+// selects text, as on any page.
// Uses a tall stand-in feed so no relays are needed.
// Run: bun tools/feed-momentum-test.js
import puppeteer from './puppeteer.js';
@@ -76,50 +75,17 @@ try {
check('...and a notch right after them too (still a trackpad)', !(await notch(1)));
await sleep(500);
- console.log('[grab and flick]');
+ console.log('[a mouse drag selects text]');
await top();
+ const b = await (await page.$('#tgt')).boundingBox();
+ await page.evaluate(() => { window.__clicks = 0; getSelection().removeAllRanges(); });
await page.mouse.move(300, 700); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 40); await sleep(8); }
- const during = await y();
await page.mouse.up();
- check('dragging up scrolls with the mouse', during >= 380 && during <= 420, during + 'px');
- const flung = await settle();
- check('letting go while moving throws it', flung > during + 400, flung + 'px');
- await top();
- await page.mouse.move(300, 700); await page.mouse.down();
- for (let i = 1; i <= 10; i++) { await page.mouse.move(300, 700 - i * 30); await sleep(8); }
- await sleep(250); // hold still
- const held = await y();
- await page.mouse.up();
- check('held still before letting go: it stays put', (await settle()) - held < 5, held + ' β†’ ' + await y());
- await top();
- const b = await (await page.$('#tgt')).boundingBox();
- await page.evaluate(() => { window.__clicks = 0; });
- await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
- for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 50, b.y + 150 - i * 15);
- await page.mouse.up(); await sleep(50);
- check('a drag that started on a button doesn\'t click it', await page.evaluate(() => window.__clicks) === 0);
- // a hand's flick often starts a little sideways
- await top();
- await page.mouse.move(b.x + 50, b.y + 150); await page.mouse.down();
- await page.mouse.move(b.x + 58, b.y + 145);
- for (let i = 1; i <= 6; i++) await page.mouse.move(b.x + 60, b.y + 145 - i * 15);
- await page.mouse.up(); await sleep(50);
- check('a flick that starts a bit sideways still scrolls', (await settle()) > 50, await y() + 'px');
- check('...and doesn\'t click', await page.evaluate(() => window.__clicks) === 0);
- await top();
- await page.mouse.move(b.x + 20, b.y + 150); await page.mouse.down();
- for (let i = 1; i <= 8; i++) await page.mouse.move(b.x + 20 + i * 30, b.y + 152);
- await page.mouse.up(); await sleep(50);
- check('a sideways drag (selecting) doesn\'t click either', await page.evaluate(() => window.__clicks) === 0);
- await sleep(350);
+ check('dragging up doesn\'t scroll the feed', (await settle()) === 0, await y() + 'px');
+ check('...it selects text', await page.evaluate(() => getSelection().toString().length > 0));
await page.click('#tgt'); await sleep(50);
- check('...but a plain click still does', await page.evaluate(() => window.__clicks) === 1);
- await top();
- await page.mouse.move(100, 500); await page.mouse.down();
- for (let i = 1; i <= 10; i++) await page.mouse.move(100 + i * 30, 505);
- await page.mouse.up();
- check('a sideways drag doesn\'t scroll (it selects text)', (await settle()) === 0);
+ check('a plain click still clicks', await page.evaluate(() => window.__clicks) === 1);
check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); }
console.log(ok ? 'PASS' : 'FAIL');
diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js
index 35b8e84..59d6f7c 100644
--- a/tools/feed-new-posts-test.js
+++ b/tools/feed-new-posts-test.js
@@ -99,21 +99,19 @@ try {
assert(!(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(0)+'"]')),'a post barely on screen starts fading too');
await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300);
assert.deepEqual(await fresh(),[],'back at the top, the rest fade once seen');
- // Only ever up: reading a new post that's newer than the oldest one, the
- // pill counts just the ones above you and the tap goes up to the nearest.
+ // Starting upward hides the pill before any new post is reached.
await test_reset();
await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading);
await sleep(300);
await page.evaluate(()=>test.arrive(3)); await sleep(300);
- await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},F(1));
- await sleep(400);
- assert.equal(await page.$eval('.feed-new-pill .n',e=>e.textContent),'1','scrolled up past two new posts: one left above');
- const yUp=await page.evaluate(()=>scrollY);
- await page.tap('.feed-new-pill'); await sleep(400);
- assert(await page.evaluate(()=>scrollY)<yUp,'the tap went up, not down to the oldest new post');
- const newest=await top(F(0));
- assert(newest>=0&&newest<=20,'...landing on the new post above: '+newest);
- await page.evaluate(()=>window.scrollTo(0,0)); await sleep(300);
+ assert(await page.$('.feed-new-pill'),'arrivals show the pill');
+ await page.evaluate(()=>window.scrollBy(0,40)); await sleep(300);
+ assert(await page.$('.feed-new-pill'),'scrolling down leaves the pill visible');
+ await page.evaluate(()=>window.scrollBy(0,-10)); await sleep(300);
+ assert(!(await page.$('.feed-new-pill')),'a small upward scroll hides the pill before reaching new posts');
+ assert((await top(F(2)))<0,'new posts are still above the viewport');
+ await page.evaluate(()=>{window.scrollBy(0,20);test.render();}); await sleep(300);
+ assert(!(await page.$('.feed-new-pill')),'scrolling down and repainting do not revive the notice');
// swipe the pill away: it goes, the page doesn't move, and it's back once more arrive
await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading);
await sleep(300);
diff --git a/tools/go-live-test.js b/tools/go-live-test.js
new file mode 100644
index 0000000..82d6c7c
--- /dev/null
+++ b/tools/go-live-test.js
@@ -0,0 +1,150 @@
+// Going live: a wallet with a coinos name opens Go live from the composer,
+// sees its camera, starts β€” the recording goes over the WebSocket to the live
+// server (coinosv3-server live/, run here beside the built app), the stream is
+// announced as its kind 30311 pointing at the HLS β€” and someone else opening
+// the stream's link watches it. Ending says so in a last 30311 carrying the
+// recording, and the recording is complete.
+// Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { naddrEncode } from 'nostr-tools/nip19';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+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 PORT = 5304;
+const dir = mkdtempSync(tmpdir() + '/go-live-');
+
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+// the registrar, as the live server asks it: the name is whoever is sending's
+let owner = null;
+const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner } : { taken: false }) });
+process.env.LIVE_DATA = dir;
+process.env.NAMES_URL = names.url.origin;
+process.env.LIVE_BASE = `http://localhost:${PORT}/live`;
+const live = await import(process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js');
+const server = Bun.serve({ port: PORT, websocket: live.websocket, fetch: async (req, srv) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/live/')) {
+ if (path === '/live/ingest') try { owner = JSON.parse(atob(new URL(req.url).searchParams.get('auth'))).pubkey; } catch {}
+ return live.handle(req, srv);
+ }
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
+ args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
+
+const relayStub = (fx) => {
+ window.__published = [];
+ const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ if (/\/live\/ingest/.test(String(a[0]))) return ws; // the live server itself
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2]) {
+ const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e)));
+ if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
+ }
+ if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+const helpers = (page) => ({
+ click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x),
+ waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; },
+ waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; },
+});
+const lives = (page) => page.evaluate(() => window.__published.filter((e) => e.kind === 30311));
+
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(relayStub, []);
+ // the registrar on the open internet is not asked: the name below is what the wallet holds
+ await page.setRequestInterception(true);
+ page.on('request', (r) => (/names\.coinos\.io|nwcpush/.test(r.url()) ? r.respond({ status: 404, body: '{}' }) : r.continue()));
+ page.on('dialog', (d) => d.accept());
+ const errs = []; page.on('pageerror', (e) => errs.push(String(e)));
+ const { click, waitText, waitFor } = helpers(page);
+ await page.goto(`http://localhost:${PORT}/`, { 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');
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate((k) => {
+ localStorage.setItem(k + ':names', JSON.stringify({ byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, base);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+
+ console.log('[getting ready]');
+ await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000);
+ await page.evaluate(() => document.querySelector('.feed-post-fab').click());
+ check('the composer has a Go live button', await waitFor(() => !!document.querySelector('.go-live-tool'), 5000));
+ await page.evaluate(() => document.querySelector('.go-live-tool').click());
+ check('Go live shows the camera', await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000));
+ check('...mirrored, as a front camera is', await page.evaluate(() => document.querySelector('video.bc-preview').classList.contains('mirror')));
+ await page.type('.go-live-title', 'Testing the live button');
+
+ console.log('\n[live]');
+ await page.evaluate(() => document.querySelector('.go-live-start').click());
+ check('starting opens the stream\'s page, with the clock and End', await waitFor(() => !!document.querySelector('.live-page .bc-bar .bc-end'), 40000));
+ const ann = (await lives(page)).at(-1);
+ const tag = (e, k) => (e.tags.find((x) => x[0] === k) || [])[1];
+ check('the stream is announced: a 30311 of yours, live, pointing at its HLS', !!ann && tag(ann, 'status') === 'live' && /\/live\/[0-9a-f]{16}\/live\.m3u8$/.test(tag(ann, 'streaming')) && tag(ann, 'title') === 'Testing the live button'
+ && ann.tags.some((x) => x[0] === 'p' && x[1] === ann.pubkey && x[3] === 'Host'), JSON.stringify(ann && ann.tags));
+ check('...by the key the server let in', ann && ann.pubkey === owner);
+ check('the page shows you, not the stream seconds behind', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); return !!v && v.videoWidth > 0 && !document.querySelector('.live-page video.live-video:not(.bc-preview)'); }, 5000));
+ const hls = tag(ann, 'streaming');
+ const m3u8 = await (await fetch(hls)).text();
+ check('the server is making HLS of it, three segments in when it\'s announced', (m3u8.match(/\.ts/g) || []).length >= 3 && !/ENDLIST/.test(m3u8), (m3u8.match(/\.ts/g) || []).length + ' segments');
+
+ console.log('\n[someone watching]');
+ const viewer = await browser.newPage();
+ await viewer.setViewport({ width: 390, height: 844 });
+ await viewer.evaluateOnNewDocument(relayStub, [ann]);
+ const v = helpers(viewer);
+ await viewer.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: ann.pubkey, identifier: tag(ann, 'd') }), { waitUntil: 'domcontentloaded' });
+ check('the stream\'s link opens it', await v.waitFor(() => !!document.querySelector('.live-page'), 15000) && await v.waitText('Testing the live button', 3000));
+ check('...and it plays', await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000),
+ JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return x && { t: x.currentTime, w: x.videoWidth, err: x.error && x.error.code }; })));
+ const st = await (await fetch(hls.replace('live.m3u8', 'stats'))).json();
+ check('the server counts the watcher', st.viewers >= 1, JSON.stringify(st));
+ await viewer.close();
+
+ console.log('\n[the end]');
+ await page.evaluate(() => document.querySelector('.bc-end').click());
+ check('End leaves the stream\'s page', await waitFor(() => !document.querySelector('.bc-bar'), 5000));
+ check('...and lets go of the camera', await page.evaluate(() => !document.querySelector('video.bc-preview')));
+ check('a last 30311 says it ended, with the recording', await waitFor(() => window.__published.some((e) => e.kind === 30311 && e.tags.some((x) => x[0] === 'status' && x[1] === 'ended') && e.tags.some((x) => x[0] === 'recording')), 8000));
+ const fin = (await lives(page)).at(-1);
+ await sleep(1500);
+ const rec = await (await fetch(tag(fin, 'recording'))).text();
+ check('...and the recording is whole', /ENDLIST/.test(rec) && (rec.match(/\.ts/g) || []).length >= 3, (rec.match(/\.ts/g) || []).length + ' segments');
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true); names.stop(true);
+ await sleep(300);
+ if (!process.env.KEEP) rmSync(dir, { recursive: true, force: true }); else console.log('kept', dir);
+}
+console.log(ok ? '\nβœ… going live: camera to stream to watcher' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/hls-upload-test.js b/tools/hls-upload-test.js
new file mode 100644
index 0000000..0a3feea
--- /dev/null
+++ b/tools/hls-upload-test.js
@@ -0,0 +1,92 @@
+// A long video posted from the app goes up as an HLS stream: made on the
+// device (two renditions, a file each, read in ranges), put on one Blossom
+// host with the playlists naming the files' addresses, and the master
+// playlist's address is what the post carries. Chrome plays it from there
+// and seeks in it. A short clip is no stream.
+// Run: bun tools/hls-upload-test.js
+import puppeteer from './puppeteer.js';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? 'βœ“' : 'βœ—'} ${n}${d ? ' β€” ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/hls-upload-');
+const make = (secs, out) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1280x720:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', String(secs), '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '60', '-c:a', 'aac', '-movflags', '+faststart', out]);
+make(95, dir + '/long.mp4');
+make(20, dir + '/short.mp4');
+const built = await Bun.build({ entrypoints: ['src/video-compress.js', 'src/media-upload.js'], target: 'browser', format: 'esm', splitting: true });
+if (!built.success) throw new Error(built.logs.join('\n'));
+const js = new Map();
+for (const o of built.outputs) js.set('/' + o.path.replace(/^\.\//, ''), await o.text());
+// a Blossom host: PUT /upload keeps the body under its hash; GET /<hash>.<ext> serves it in ranges
+const blobs = new Map(), types = new Map();
+const server = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(req) {
+ const u = new URL(req.url);
+ if (js.has(u.pathname)) return new Response(js.get(u.pathname), { headers: { 'content-type': 'text/javascript' } });
+ if (u.pathname === '/long.mp4' || u.pathname === '/short.mp4') return new Response(Bun.file(dir + u.pathname));
+ if (u.pathname === '/upload' && req.method === 'HEAD') return new Response(null, { status: 401 });
+ if (u.pathname === '/upload' && req.method === 'PUT') {
+ const body = new Uint8Array(await req.arrayBuffer());
+ const x = createHash('sha256').update(body).digest('hex');
+ blobs.set(x, body); types.set(x, req.headers.get('content-type'));
+ return Response.json({ url: `${u.origin}/${x}`, sha256: x, size: body.length });
+ }
+ const m = /^\/([0-9a-f]{64})(\.\w+)?$/.exec(u.pathname);
+ if (m && blobs.has(m[1])) {
+ const buf = blobs.get(m[1]), r = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const headers = { 'content-type': types.get(m[1]), 'accept-ranges': 'bytes' };
+ if (r) { const a = +r[1], e = r[2] ? Math.min(+r[2], buf.length - 1) : buf.length - 1; return new Response(buf.subarray(a, e + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${a}-${e}/${buf.length}` } }); }
+ return new Response(buf, { headers });
+ }
+ return new Response('<!doctype html><video muted></video>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const run = (name) => page.evaluate(async (name) => {
+ const vc = await import('/video-compress.js'), mu = await import('/media-upload.js');
+ const blob = await (await fetch('/' + name)).blob();
+ const file = new File([blob], name, { type: 'video/mp4' });
+ const t0 = performance.now();
+ const stream = await vc.streamVideo(file);
+ if (!stream) return { stream: null };
+ const made = Math.round(performance.now() - t0);
+ let progress = 0;
+ const url = await mu.uploadStream(stream, async (ev) => ({ ...ev, id: 'x', pubkey: 'y', sig: 'z' }), { servers: [location.origin], onProgress: (p) => { progress = p; } });
+ return { url, made, progress, files: [...stream.files.keys()], dim: stream.dim, seconds: stream.seconds };
+ }, name);
+ const short = await run('short.mp4');
+ check('a 20 s clip is not made a stream', short.stream === null);
+ const long = await run('long.mp4');
+ check('a 95 s clip is: a master playlist, one per rendition and the sound, a file each', long.url && long.files.length >= 7, (long.files || []).join(' ') + ' in ' + long.made + ' ms');
+ check('...its address is the master playlist on the host', /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(long.url || ''), long.url);
+ check('...the upload reported its progress to the end', long.progress === 1, String(long.progress));
+ check('...said as 1280x720, 95 s', long.dim === '1280x720' && Math.round(long.seconds) === 95, long.dim + ', ' + long.seconds);
+ const master = await (await fetch(long.url)).text();
+ const lists = master.split('\n').flatMap((l) => [...l.matchAll(/URI="([^"]+)"/g)].map((m) => m[1]).concat(l && !l.startsWith('#') ? [l] : []));
+ check('the master playlist names its playlists by their addresses on the host', lists.length >= 3 && lists.every((u) => /^http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{64}\.m3u8$/.test(u)), lists.join(' '));
+ check('...720p and 360p', /RESOLUTION=1280x720/.test(master) && /RESOLUTION=640x360/.test(master));
+ const media = await (await fetch(lists.at(-1))).text();
+ check('each playlist names its file by its address too', media.split('\n').filter((l) => l && !l.startsWith('#')).every((u) => /\/[0-9a-f]{64}\.mp4$/.test(u)) && /URI="http[^"]+\.mp4"/.test(media));
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 2 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, dur: Math.round(v.duration), err: v.error && v.error.message };
+ v.currentTime = 70;
+ const t1 = performance.now();
+ while (v.currentTime < 71 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, long.url);
+ check('Chrome plays the stream from its address', played.first.t >= 2 && played.first.w > 0 && played.first.dur === 95, JSON.stringify(played.first));
+ check('...and seeks in it', played.after >= 71, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\nβœ… a long video goes up as an HLS stream' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
new file mode 100644
index 0000000..4b8172d
--- /dev/null
+++ b/tools/live-stream-test.js
@@ -0,0 +1,197 @@
+// Live streams (NIP-53): a feed shows its people's streams in a row above
+// the posts (a stream gone quiet for an hour is over), a tap opens the
+// stream's page β€” the player playing the HLS stream, the chat and zaps as
+// they come, a chat line sent as a kind 1311 β€” and the address bar names it
+// (/naddr1…). A post linking the stream shows it as a card, the host's
+// profile says they're on, Back stops the player, the browser without HLS
+// of its own plays it through hls.js, and a zap carries the stream's `a`.
+// Run: bun tools/live-stream-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { naddrEncode } from 'nostr-tools/nip19';
+import { buildZapRequest } from '../src/lnurl.js';
+import { setZapAddr } from '../src/live.js';
+import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+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 PORT = 5302;
+const now = Math.floor(Date.now() / 1000);
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const HOST = key(), SERVICE = key(), FAN = key(), STALE = key();
+
+// a stream: 30 s of HLS, its playlist without an end (as a live one is)
+const dir = mkdtempSync(tmpdir() + '/live-stream-');
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '50', '-c:a', 'aac', '-f', 'hls', '-hls_time', '2', '-hls_list_size', '0', dir + '/index.m3u8']);
+writeFileSync(dir + '/index.m3u8', readFileSync(dir + '/index.m3u8', 'utf8').replace('#EXT-X-ENDLIST\n', ''));
+const STREAM = `http://localhost:${PORT}/live/index.m3u8`;
+
+// zap.stream's shape: the service signs, the streamer is the Host
+const live = finalizeEvent({ kind: 30311, created_at: now - 30, content: '', tags: [
+ ['d', 'stream-1'], ['title', 'Building coinos live'], ['summary', 'Hacking on the wallet'], ['status', 'live'],
+ ['streaming', STREAM], ['current_participants', '12'], ['starts', String(now - 600)],
+ ['p', HOST.pk, '', 'Host'], ['relays', 'wss://relay.example.com'],
+] }, SERVICE.sk);
+const A = `30311:${SERVICE.pk}:stream-1`;
+const NADDR = naddrEncode({ kind: 30311, pubkey: SERVICE.pk, identifier: 'stream-1' });
+// still says live, but nobody has refreshed it in two hours
+const stale = finalizeEvent({ kind: 30311, created_at: now - 7200, content: '', tags: [
+ ['d', 'old'], ['title', 'Forgotten stream'], ['status', 'live'], ['streaming', STREAM], ['p', HOST.pk, '', 'host'],
+] }, STALE.sk);
+const chat = finalizeEvent({ kind: 1311, created_at: now - 20, content: 'hello from the chat', tags: [['a', A, '', 'root']] }, FAN.sk);
+const zapReq = finalizeEvent({ kind: 9734, created_at: now - 15, content: 'great stream', tags: [['p', HOST.pk], ['a', A], ['amount', '21000']] }, FAN.sk);
+const zap = finalizeEvent({ kind: 9735, created_at: now - 15, content: '', tags: [['p', HOST.pk], ['a', A], ['bolt11', 'lnbc210n1invalid'], ['description', JSON.stringify(zapReq)], ['P', FAN.pk]] }, SERVICE.sk);
+const post = finalizeEvent({ kind: 1, created_at: now - 100, content: 'come watch nostr:' + NADDR, tags: [] }, HOST.sk);
+const FIXTURES = [live, stale, chat, zap];
+const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk);
+
+// the site as built (the split build: hls.js arrives as its own chunk)
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: PORT, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } });
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+
+// the relays: they hold the fixtures, answer by the filter, and keep what's published
+const relayStub = (fx) => {
+ window.__published = []; window.__reqs = [];
+ const subs = [];
+ // a new event, to every open subscription it matches (a relay's live feed)
+ window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); };
+ const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
+ && (!f.since || e.created_at >= f.since)
+ && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2]) {
+ window.__reqs.push([a[0], ...m.slice(2)]);
+ subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, limit: undefined }))]);
+ const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e)));
+ if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
+ }
+ if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+};
+
+const newPage = async (noHls = false) => {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(relayStub, FIXTURES);
+ // Firefox: no HLS of its own
+ if (noHls) await page.evaluateOnNewDocument(() => {
+ const real = HTMLMediaElement.prototype.canPlayType;
+ HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : real.call(this, t); };
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ page.errs = errs;
+ return page;
+};
+const helpers = (page) => ({
+ click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x),
+ waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; },
+ waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; },
+});
+const playing = () => { const v = document.querySelector('video.live-video'); return !!v && !v.paused && v.currentTime > 1 && v.videoWidth > 0; };
+
+try {
+ console.log('[a zap on a stream names it]');
+ setZapAddr(live.id, A);
+ const req = await buildZapRequest({ amountMsat: 21000, relays: ['wss://r'], recipientPk: HOST.pk, eventId: live.id, signFn: (e) => e });
+ check('the zap request carries the stream\'s a tag beside its e', req.tags.some((x) => x[0] === 'a' && x[1] === A) && req.tags.some((x) => x[0] === 'e' && x[1] === live.id));
+
+ const page = await newPage();
+ const { click, waitText, waitFor } = helpers(page);
+ await page.goto(`http://localhost:${PORT}/`, { 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');
+ 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: 'Stream Host', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, HOST.pk, [post]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+
+ console.log('\n[the feed]');
+ check('a followed host\'s stream shows above the posts', await waitFor(() => !!document.querySelector('.live-strip .live-tile'), 15000));
+ const tile = await page.evaluate(() => [...document.querySelectorAll('.live-strip .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
+ check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile));
+ check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream'))));
+ check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000));
+ check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000));
+ await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click());
+ check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000));
+ await page.goBack();
+ await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-strip'), 5000);
+
+ console.log('\n[the stream\'s page]');
+ await page.evaluate(() => document.querySelector('.live-strip .live-tile').click());
+ check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname));
+ check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; })));
+ check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM));
+ check('the chat shows what was said', await waitText('hello from the chat', 8000));
+ check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
+ await page.focus('.live-input');
+ await page.keyboard.type('first time here');
+ await page.keyboard.press('Enter');
+ check('a line typed in the chat shows at once', await waitText('first time here', 3000));
+ const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311));
+ check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags));
+ check('the player keeps playing through the renders', await waitFor(playing, 3000));
+
+ console.log('\n[leaving]');
+ await page.goBack();
+ check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
+ check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ await page.evaluate(() => document.querySelector('.notes-feed .live-card').click());
+ check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000));
+ await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); });
+ check('the host\'s profile says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000));
+ check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ check('no page errors', !page.errs.length, page.errs.join(' | '));
+ await page.close();
+
+ console.log('\n[a browser without HLS of its own, opening a link]');
+ const ff = await newPage(true);
+ const f = helpers(ff);
+ await ff.goto(`http://localhost:${PORT}/${NADDR}`, { waitUntil: 'domcontentloaded' });
+ check('a /naddr1… link opens the stream, no wallet needed', await f.waitFor(() => !!document.querySelector('.live-page'), 15000) && await f.waitText('Building coinos live', 3000));
+ check('hls.js plays it', await f.waitFor(playing, 25000), JSON.stringify(await ff.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, src: v.currentSrc.slice(0, 30), err: v.error && v.error.code }; })));
+ check('...through Media Source', await ff.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
+ check('a visitor reads the chat', await f.waitText('hello from the chat', 8000));
+ await ff.evaluate((e) => window.__push(e), ended);
+ check('the stream ending while watched says so, the player gone', await f.waitText('This stream has ended', 5000) && await ff.evaluate(() => !document.querySelector('video.live-video')));
+ check('no page errors', !ff.errs.length, ff.errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\nβœ… live streams: found, watched, chatted in' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-servers-test.js b/tools/media-servers-test.js
new file mode 100644
index 0000000..c5cad2f
--- /dev/null
+++ b/tools/media-servers-test.js
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import { parseMediaServers, customMediaServers, mediaServers, saveMediaServers, resetMediaServers } from '../src/media-servers.js';
+import { uploadPublicMedia, uploadStream, PUBLIC_MEDIA_SERVERS } from '../src/media-upload.js';
+const stored = new Map();
+globalThis.localStorage = { getItem: k => stored.get(k) ?? null, setItem: (k, v) => stored.set(k, v), removeItem: k => stored.delete(k) };
+assert.equal(customMediaServers(), null);
+assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS);
+assert.deepEqual(saveMediaServers('https://FIRST.example/\nhttps://first.example https://second.example/'), ['https://first.example', 'https://second.example']);
+for (const bad of ['', 'http://host.test', 'https://host.test/path', 'https://user:[email protected]', 'https://host.test?x=1', 'https://host.test#x', 'https://good.test nope', Array.from({length:7}, (_,i)=>`https://host${i}.test`).join('\n')]) {
+ assert.throws(() => saveMediaServers(bad));
+ assert.equal(customMediaServers()[0], 'https://first.example', 'invalid input must not overwrite saved servers');
+}
+const calls = [];
+const fetcher = async (url, opts) => {
+ calls.push(url);
+ if (url.startsWith('https://first.')) return new Response('', {status:503});
+ if (opts.method === 'HEAD') return new Response('');
+ return Response.json({url: 'https://second.example/' + 'a'.repeat(64)});
+};
+const sign = async e => e;
+await uploadPublicMedia(new File(['picture'], 'photo.png', {type:'image/png'}), sign, {fetcher});
+assert(calls.some(u => u.startsWith('https://second.')));
+assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u)));
+calls.length = 0;
+await uploadStream({master:'master.m3u8', files:new Map([
+ ['video.mp4', new File(['video'], 'video.mp4', {type:'video/mp4'})],
+ ['master.m3u8', new File(['#EXTM3U\nvideo.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})],
+])}, sign, {fetcher});
+assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u)));
+await assert.rejects(uploadPublicMedia(new File(['x'], 'x'), sign, {fetcher: async url => {
+ assert(calls.includes(url), 'must not fall back to public hosts');
+ return new Response('', {status:503});
+}}));
+// Exercise the attachment uploader used by DMs, communities and White Noise.
+const source = await Bun.file('src/features/messages.js').text();
+const start = source.indexOf(' async function uploadEncrypted(');
+const end = source.indexOf(' const imetaTag', start);
+const encryptedCalls = [];
+const uploadEncrypted = new Function('mediaServers', 'MEDIA_SERVERS', 'fetch', 't',
+ source.slice(start, end) + '\nreturn uploadEncrypted;')(
+ mediaServers, ['https://public.example'], async (url, init) => {
+ encryptedCalls.push({url, init});
+ return Response.json({url: url.replace('/upload', '/cipher')});
+ }, key => key);
+const cipher = new Uint8Array([9, 8, 7]);
+const urls = await uploadEncrypted({pubkey:'a'.repeat(64), signer:{signEvent: sign}}, {cipher, x:'b'.repeat(64)});
+assert.deepEqual(urls, ['https://first.example/cipher', 'https://second.example/cipher']);
+assert(encryptedCalls.every(c => c.init.body === cipher && c.init.headers['content-type'] === 'application/octet-stream'));
+resetMediaServers();
+assert.equal(customMediaServers(), null);
+assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS);
+console.log('βœ“ Media server validation, persistence, custom-only file/HLS/encrypted attachment uploads, failover, and reset');
diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js
index 6f62007..657ea75 100644
--- a/tools/pager-swipe-fly-test.js
+++ b/tools/pager-swipe-fly-test.js
@@ -1,8 +1,10 @@
// On a phone, a fast flick up or down in the media pager flies past several
// posts and lands snapped on one, the way a spin of the wheel does; a
// gentler swipe is one post, as before; a fling at the end lands on the
-// last post rather than wrapping past it; a finger put down mid-flight
-// catches it. Run: bun tools/pager-swipe-fly-test.js
+// last post and goes no further (no going round to the first); a tap
+// mid-flight stops it on the post most on screen, and a swipe back from
+// there goes to the one before.
+// Run: bun tools/pager-swipe-fly-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
@@ -120,15 +122,18 @@ try {
const seen = await page.evaluate(() => document.querySelector('.mp-position').textContent);
check('the counter says where it landed', seen === landed + ' / ' + N, seen);
- console.log('\n[a flick near the end coasts round to the start]');
+ console.log('\n[the ends are ends]');
while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); }
const before = await at();
await swipe(600, 80, 3); await settled();
- check('from post ' + before + ' (a flight\'s worth would be past the end), a flick comes round to the first post', before >= 7 && (await at()) === 1, String(await at()));
- await swipe(-600, 80, 3); await settled();
- check('…and a flick back up from the first comes round to the last', (await at()) === N, String(await at()));
+ check('from post ' + before + ', a flick whose flight would be past the end lands on the last post', before === 7 && (await at()) === N, String(await at()));
await swipe(600, 80, 3); await settled();
- check('…a flick down from the last, to the first again', (await at()) === 1, String(await at()));
+ check('…a flick on from the last stays there (no going round to the first)', (await at()) === N, String(await at()));
+ await page.keyboard.press('ArrowDown'); await settled();
+ check('…and so does ↓', (await at()) === N, String(await at()));
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
+ await swipe(-600, 80, 3); await settled();
+ check('a flick back up from the first stays on the first', (await at()) === 1, String(await at()));
await swipe(320, 130); await settled();
check('a gentle swipe from the first: the second, as ever', (await at()) === 2, String(await at()));
@@ -141,17 +146,39 @@ try {
check('a fast flick whose last move was handled 300ms late still flies', typeof (await at()) === 'number' && (await at()) >= 5, String(await at()));
while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
- console.log('\n[a finger down mid-flight catches it]');
+ console.log('\n[a tap mid-flight stops it on the post most on screen]');
+ // the post most on screen as the finger comes down (its number), then the touch, in one go
+ const down = () => page.evaluate(() => {
+ const sc = document.querySelector('.mp-scroll'), y = sc.scrollTop;
+ const g = [...sc.querySelectorAll(':scope > .mp-group:not(.mp-wait):not(.mp-off)')].reduce((b, x) => (Math.abs(x.offsetTop - y) < Math.abs(b.offsetTop - y) ? x : b));
+ const target = window.__tt = document.elementFromPoint(195, 500), t = new Touch({ identifier: 2, target, clientX: 195, clientY: 500, pageX: 195, pageY: 500 });
+ target.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], changedTouches: [t], targetTouches: [t] }));
+ return Number(/p(\d+)\.png/.exec(g.querySelector('.mp-slide').getAttribute('data-k'))[1]);
+ });
+ // the same finger moving (to y), or lifted there
+ const finger = (y, up) => page.evaluate(([y, up]) => {
+ const target = window.__tt, t = new Touch({ identifier: 2, target, clientX: 195, clientY: y, pageX: 195, pageY: y });
+ target.dispatchEvent(new TouchEvent(up ? 'touchend' : 'touchmove', { bubbles: true, cancelable: true, touches: up ? [] : [t], changedTouches: [t], targetTouches: up ? [] : [t] }));
+ }, [y, up]);
+ await swipe(600, 80, 3);
+ await sleep(150);
+ const flying = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
+ const most = await down();
+ await sleep(60); await finger(500, true);
+ await sleep(250);
+ check('a tap mid-flight: on the post most on screen in a moment', flying > 0 && (await at()) === most, most + ' wanted, ' + (await at()));
+ await settled();
+ check('…and stays there', (await at()) === most, String(await at()));
+ check('…the tap only stopped it (no details brought up)', !(await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show'))));
+
+ console.log('\n[a swipe back mid-flight: the post before]');
+ while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); }
await swipe(600, 80, 3);
- await sleep(120);
- const mid = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
- await page.touchscreen.touchStart(195, 500);
- await page.touchscreen.touchMove(195, 480); await sleep(30);
- await page.touchscreen.touchMove(195, 470); await sleep(200);
- const held = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop);
- await page.touchscreen.touchEnd(); await settled();
- check('the flight stops under the finger', mid > 0 && Math.abs(held - mid) < 844, mid + ' β†’ ' + held);
- check('…and lands square on a post when it lifts', (await at()) !== 'between', String(await at()));
+ await sleep(150);
+ const most2 = await down();
+ for (const y of [520, 560, 640, 760]) { await finger(y); await sleep(20); }
+ await finger(760, true); await settled();
+ check('caught on post ' + most2 + ' and swiped back: post ' + (most2 - 1), most2 > 1 && (await at()) === most2 - 1, String(await at()));
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βœ… a fast flick flies through the pager' : '\n❌ failed');
diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js
index aac681a..2a78598 100644
--- a/tools/profile-pager-test.js
+++ b/tools/profile-pager-test.js
@@ -105,18 +105,15 @@ try {
await page.keyboard.press('ArrowDown');
check('down again: her last', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowDown'); await sleep(600);
- check('past her last wraps to her first post', await resting('a2.png'), await curPic());
- check('counter counts the first post once', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3');
+ check('past her last it stays on her last (no going round)', await resting('d.png'), await curPic());
+ check('counter still at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
check('no loading slide', await page.$('.mp-tail') === null);
- await page.keyboard.press('ArrowUp'); await resting('d.png');
- check('up from first wraps to last', await curPic() === 'd.png');
- check('counter at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3');
await page.touchscreen.touchStart(195, 650);
for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(195, 650 - 40 * i); await sleep(16); }
- await page.touchscreen.touchEnd();
- check('touch swipe wraps to first post', await resting('a2.png'), await curPic());
- await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: -3, deltaMode: 1, bubbles: true, cancelable: true })));
- check('wheel wraps back to last post', await resting('d.png'), await curPic());
+ await page.touchscreen.touchEnd(); await sleep(600);
+ check('a touch swipe on from her last stays there', await resting('d.png'), await curPic());
+ await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: 3, deltaMode: 1, bubbles: true, cancelable: true }))); await sleep(600);
+ check('…and so does the wheel', await resting('d.png'), await curPic());
await page.keyboard.press('ArrowUp');
check('up: her post before', await resting('b.png'), await curPic());
await page.keyboard.press('Escape');
diff --git a/tools/quoted-clip-fullscreen-test.js b/tools/quoted-clip-fullscreen-test.js
new file mode 100644
index 0000000..bd8dd45
--- /dev/null
+++ b/tools/quoted-clip-fullscreen-test.js
@@ -0,0 +1,90 @@
+// A clip in the post a reply answers (the "Replying to" card in the
+// Conversations view) isn't one of the feed's own, so the media pager can't
+// take it: a tap on it, or the player's own full-screen button, puts it full
+// screen the browser's way. Both used to do nothing at all β€” the full-screen
+// handler took the clip back out of full screen for a pager that then
+// wouldn't open.
+// Run: bun tools/quoted-clip-fullscreen-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 PORT = 5303;
+const CLIP = '/tmp/coinos-quoted-clip.webm';
+if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x568:rate=10', '-t', '6', '-c:v', 'libvpx', '-b:v', '200k', CLIP]);
+const SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago, sk, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk);
+const B1 = post(`have you seen this http://localhost:${PORT}/clip.webm`, 400, SKB);
+const R1 = post('alice answers the clip', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: PORT, 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', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+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 / 100; i++) { if (await page.evaluate(fn)) return true; await sleep(100); } return false; };
+const SEL = '.note-context .quote-card video.note-video';
+const state = () => page.evaluate((sel) => {
+ const v = document.querySelector(sel), fs = document.fullscreenElement;
+ return { fs: fs ? (fs === v ? 'video' : fs.tagName + '.' + fs.className) : null, pager: !!document.querySelector('.media-pager'), thread: !!document.querySelector('.thread-card') };
+}, SEL);
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(`http://localhost:${PORT}/`, { 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, alice, bob, roots, replies]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(roots));
+ localStorage.setItem(k + ':feedNotes:following~replies', JSON.stringify(replies));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice', t: Date.now() }, [bob]: { name: 'Bob', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, ALICE, BOB, [B1], [R1]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await waitFor(() => !!document.querySelector('.media-mode[data-mode="replies"]'), 20000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click());
+ check('the reply shows the clip in its "Replying to" card', await waitFor(() => !!document.querySelector('.note-context .quote-card video.note-video'), 20000));
+ await page.evaluate((sel) => document.querySelector(sel).scrollIntoView({ block: 'center' }), SEL);
+ await sleep(800);
+
+ console.log('\n[a tap on the picture]');
+ const box = await (await page.$(SEL)).boundingBox();
+ await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.25); // above the middle: not the play button
+ check('the clip goes full screen', await waitFor(() => document.fullscreenElement && document.fullscreenElement.matches('video'), 4000), JSON.stringify(await state()));
+ await sleep(600);
+ check('...and stays there (not taken back out)', (await state()).fs === 'video', JSON.stringify(await state()));
+ check('...and no thread opened under it', !(await state()).thread);
+ await page.evaluate(() => document.exitFullscreen());
+ await waitFor(() => !document.fullscreenElement, 3000);
+
+ console.log('\n[the player\'s full-screen button]');
+ await page.evaluate((sel) => document.querySelector(sel).requestFullscreen(), SEL); // as the β›Ά on its bar does
+ await sleep(800);
+ check('full screen, and it stays', (await state()).fs === 'video', JSON.stringify(await state()));
+ check('no page errors', !errs.length, errs.join(' | '));
+} catch (e) { console.error(e); ok = false; } finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\nβœ… a quoted post\'s clip goes full screen' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js
index 85250c0..93726e1 100644
--- a/tools/video-fallback-test.js
+++ b/tools/video-fallback-test.js
@@ -37,6 +37,12 @@ const server = Bun.serve({ port: 0, fetch: (req) => {
} });
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();
+// a browser without HLS of its own (Firefox): a clip too big to play as it
+// is waits for a tap here, rather than streaming (video-hls-play-test)
+await page.evaluateOnNewDocument(() => {
+ const can = HTMLMediaElement.prototype.canPlayType;
+ HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : can.call(this, t); };
+});
await page.setRequestInterception(true);
const asked = []; // [kind, clip url]
page.on('request', (r) => {
diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js
new file mode 100644
index 0000000..f1aea1c
--- /dev/null
+++ b/tools/video-hls-play-test.js
@@ -0,0 +1,133 @@
+// A long clip in the feed plays as HLS from vmedia (a rendition the
+// connection carries, in pieces made as watched), and so does one too big
+// to play as it is (past 1080p: it used to wait for a tap); a short one as it is,
+// and a post linking an .m3u8 stream plays that. vmedia runs here for real,
+// on a local copy of the clip.
+// Run: bun tools/video-hls-play-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 { readFileSync, rmSync } 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 DIR = process.env.CLIP_DIR || '/tmp';
+const ff = (out, codec) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=128x128:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3',
+ '-t', '3', ...codec, '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', out]);
+ if (p.exitCode !== 0) { console.log('ffmpeg failed: ' + p.stderr); process.exit(1); }
+ return readFileSync(out);
+};
+const SHORT = ff(DIR + '/coinos-hls-short.mp4', ['-c:v', 'libx264']);
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '100', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-long.mp4']);
+const LONG = readFileSync(DIR + '/coinos-hls-long.mp4');
+// short but past 1080p: it used to wait for a tap with its cost on it
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=1440x1440:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-big.mp4']);
+const BIG = readFileSync(DIR + '/coinos-hls-big.mp4');
+// vmedia itself, reading the clip from here
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => new Response(new URL(req.url).pathname === '/big.mp4' ? BIG : LONG) });
+const local = (src) => src.replace('https://media.test/', `http://127.0.0.1:${files.port}/`);
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = DIR + '/coinos-hls-vmedia';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64', '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout;
+
+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 CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8', big: 'https://media.test/big.mp4' };
+const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300), post('square ' + CLIP.big, 400)];
+
+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')) 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', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.setRequestInterception(true);
+const asked = []; // [kind, clip url]
+// vmedia's answers, from the real thing; the clip's host serves the files
+const vmedia = async (u) => {
+ if (u.pathname === '/vmedia/hls.m3u8') u.searchParams.set('u', local(u.searchParams.get('u')));
+ const res = await vm.handle(new Request('http://127.0.0.1' + u.pathname + u.search));
+ return { status: res.status, contentType: res.headers.get('content-type') || 'text/plain', body: Buffer.from(await res.arrayBuffer()), headers: { 'access-control-allow-origin': '*' } };
+};
+page.on('request', async (r) => {
+ const u = new URL(r.url());
+ if (u.pathname.startsWith('/vmedia/hls')) {
+ asked.push(['hls', u.pathname]);
+ return r.respond(await vmedia(u));
+ }
+ if (u.host === 'v3.coinos.io' && u.pathname.startsWith('/vmedia/')) return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
+ if (u.host === 'media.test') {
+ asked.push([r.resourceType(), r.url()]);
+ // the stream a post links: vmedia's of the long clip, served from its host
+ if (u.pathname === '/show/master.m3u8') { const x = new URL('https://v3.coinos.io/vmedia/hls.m3u8'); x.searchParams.set('u', CLIP.long); return r.respond(await vmedia(x)); }
+ return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : u.pathname === '/big.mp4' ? BIG : SHORT, headers: { 'access-control-allow-origin': '*' } });
+ }
+ 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, arg, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
+const box = (word) => `[...document.querySelectorAll('.note-video-box')].find((b) => b.closest('[data-key]') && b.closest('[data-key]').textContent.includes('${word}'))`;
+const player = (word) => page.evaluate(new Function(`const b = ${box(word)}; const v = b && b.querySelector('video');
+ return v && { src: v.getAttribute('src') || '', poster: v.getAttribute('poster') || '', w: v.videoWidth, t: v.currentTime, nopic: !!b.querySelector('.vid-nopic'), note: (b.querySelector('.vid-nopic') || {}).textContent || '' };`));
+const show = (word) => page.evaluate(new Function(`const b = ${box(word)}; b.scrollIntoView({ block: 'center' }); const c = b.querySelector('.vid-heavy'); if (c) c.click(); return !!b;`));
+
+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 four clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 4, null, 20000),
+ String(await page.evaluate(() => document.querySelectorAll('.note-video-box video').length)));
+
+ console.log('\n[a long clip]');
+ await show('lecture');
+ check('plays as HLS from vmedia', await waitFor(new Function(`const v = ${box('lecture')}?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('lecture')));
+ check('...in its pieces', asked.some(([k, p]) => k === 'hls' && /\.ts$/.test(p)), JSON.stringify(asked.filter(([k]) => k === 'hls').slice(0, 4)));
+
+ console.log('\n[a short clip past 1080p]');
+ await page.evaluate(new Function(`${box('square')}.scrollIntoView({ block: 'center' })`));
+ check('plays by itself as HLS, no tap-to-play cover', await waitFor(new Function(`const b = ${box('square')}, v = b?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.3 && !b.querySelector('.vid-heavy');`), null, 20000),
+ JSON.stringify(await player('square')));
+
+ console.log('\n[a short clip]');
+ await show('quick');
+ check('plays the original', await waitFor(new Function(`const v = ${box('quick')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.short}' && v.videoWidth > 0;`), null, 15000),
+ JSON.stringify(await player('quick')));
+
+ console.log('\n[a post linking a stream]');
+ await show('episode');
+ check('the .m3u8 plays in the feed', await waitFor(new Function(`const v = ${box('episode')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.stream}' && v.videoWidth > 0 && v.currentTime > 0.5;`), null, 20000),
+ JSON.stringify(await player('episode')));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); files.stop(true); rmSync(DIR + '/coinos-hls-vmedia', { recursive: true, force: true }); }
+console.log(ok ? '\nβœ… long clips and streams play as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/vmedia-hls-test.js b/tools/vmedia-hls-test.js
new file mode 100644
index 0000000..05961a8
--- /dev/null
+++ b/tools/vmedia-hls-test.js
@@ -0,0 +1,84 @@
+// vmedia's HLS: a clip's playlists come from its length before anything is
+// made, segments are made from wherever the player asks (a seek starts the
+// encoder there), the encoder waits once well ahead of the player, and
+// Chrome plays the stream and seeks in it.
+// Run: bun tools/vmedia-hls-test.js
+import puppeteer from './puppeteer.js';
+import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+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 dir = mkdtempSync(tmpdir() + '/vmedia-hls-');
+const clip = dir + '/clip.mp4';
+// a minute of 1080p with sound
+Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '90', '-c:a', 'aac', '-movflags', '+faststart', clip]);
+const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(Bun.file(clip)) });
+const SRC = `http://127.0.0.1:${files.port}/clip.mp4`;
+process.env.VMEDIA_LOCAL = '1';
+process.env.DATA = dir + '/data';
+const vm = await import('../vmedia/server.js');
+vm.startTrim();
+const api = Bun.serve({ port: 0, hostname: '127.0.0.1', idleTimeout: 0, fetch: vm.handle });
+const V = `http://127.0.0.1:${api.port}/vmedia`;
+const page0 = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => {
+ const u = new URL(req.url);
+ if (u.pathname.startsWith('/vmedia')) return fetch(V.replace('/vmedia', '') + u.pathname + u.search, { headers: req.headers });
+ return new Response('<!doctype html><video muted playsinline></video>', { headers: { 'content-type': 'text/html' } });
+} });
+let browser;
+try {
+ console.log('[the playlists]');
+ const master = await (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent(SRC)}`)).text();
+ check('a master playlist with a 360p and a 720p rendition', /RESOLUTION=640x360/.test(master) && /RESOLUTION=1280x720/.test(master), master.replace(/\n/g, ' | ').slice(0, 300));
+ const key = /\/vmedia\/hls\/([0-9a-f]{64})\/360\.m3u8/.exec(master)?.[1];
+ const media = await (await fetch(`${V}/hls/${key}/720.m3u8`)).text();
+ const segs = media.split('\n').filter((l) => /\.ts$/.test(l));
+ check('the 720p playlist lists the whole minute in 4 s segments, made or not', segs.length === 15 && /#EXT-X-ENDLIST/.test(media), segs.length + ' segments');
+ check('...and nothing has been encoded yet', !readdirSync(dir + '/data/hls/' + key).some((f) => f === '720'));
+ check('a source that is not public https is refused', (await fetch(`${V}/hls.m3u8?u=${encodeURIComponent('http://10.0.0.1/x.mp4')}`)).status === 400);
+
+ console.log('\n[segments, made as asked]');
+ let t0 = Date.now();
+ const s0 = await fetch(`${V}/hls/${key}/720/00000.ts`);
+ const b0 = await s0.arrayBuffer();
+ check('the first segment comes back as MPEG-TS', s0.status === 200 && new Uint8Array(b0)[0] === 0x47, s0.status + ', ' + b0.byteLength + ' bytes in ' + (Date.now() - t0) + ' ms');
+ t0 = Date.now();
+ const s10 = await fetch(`${V}/hls/${key}/720/00012.ts`);
+ await s10.arrayBuffer();
+ check('a segment far ahead (a seek) is made from there, not after everything before it', s10.status === 200 && Date.now() - t0 < 8000, (Date.now() - t0) + ' ms');
+ const probe = (n) => JSON.parse(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_entries', 'format=start_time,duration', '-of', 'json', `${dir}/data/hls/${key}/720/${String(n).padStart(5, '0')}.ts`]).stdout.toString()).format;
+ const p0 = probe(0), p12 = probe(12);
+ check('...its times are where the clip has them (48 s on from the first)', Math.abs((+p12.start_time - +p0.start_time) - 48) < 0.2, p0.start_time + ' β†’ ' + p12.start_time);
+ await sleep(4000);
+ const made = readdirSync(`${dir}/data/hls/${key}/720`).filter((f) => /^\d{5}\.ts$/.test(f));
+ check('the encoder runs on from the seek to the end', made.includes('00014.ts'), made.join(' '));
+
+ console.log('\n[Chrome plays it]');
+ 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.goto(page0.url.href);
+ const hlsSrc = `${page0.url.origin}/vmedia/hls.m3u8?u=${encodeURIComponent(SRC)}`;
+ const played = await page.evaluate(async (src) => {
+ const v = document.querySelector('video');
+ v.src = src;
+ await v.play().catch(() => {});
+ const t0 = performance.now();
+ while (v.currentTime < 1.5 && performance.now() - t0 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ const first = { t: v.currentTime, w: v.videoWidth, err: v.error && v.error.message, dur: v.duration };
+ v.currentTime = 30;
+ const t1 = performance.now();
+ while (v.currentTime < 31.5 && performance.now() - t1 < 20000 && !v.error) await new Promise((r) => setTimeout(r, 100));
+ return { first, after: v.currentTime, err: v.error && v.error.message };
+ }, hlsSrc);
+ check('it plays from the start', played.first.t >= 1.5 && played.first.w > 0, JSON.stringify(played.first));
+ check('...knows the whole length', Math.abs(played.first.dur - 60) < 1, String(played.first.dur));
+ check('...and plays on after a seek to the middle', played.after >= 31.5, JSON.stringify(played));
+} catch (e) { console.error(e); ok = false; } finally {
+ if (browser) await browser.close();
+ files.stop(true); api.stop(true); page0.stop(true);
+ rmSync(dir, { recursive: true, force: true });
+}
+console.log(ok ? '\nβœ… vmedia serves long clips as HLS' : '\n❌ failed');
+process.exit(ok ? 0 : 1);