diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/call-test.js | 188 | ||||
| -rw-r--r-- | tools/call-wake-test.js | 214 | ||||
| -rw-r--r-- | tools/composer-media-test.js | 21 | ||||
| -rw-r--r-- | tools/feed-media-views-test.js | 2 | ||||
| -rw-r--r-- | tools/feed-momentum-test.js | 48 | ||||
| -rw-r--r-- | tools/feed-new-posts-test.js | 20 | ||||
| -rw-r--r-- | tools/go-live-test.js | 150 | ||||
| -rw-r--r-- | tools/hls-upload-test.js | 92 | ||||
| -rw-r--r-- | tools/live-stream-test.js | 197 | ||||
| -rw-r--r-- | tools/media-servers-test.js | 52 | ||||
| -rw-r--r-- | tools/pager-swipe-fly-test.js | 61 | ||||
| -rw-r--r-- | tools/profile-pager-test.js | 15 | ||||
| -rw-r--r-- | tools/quoted-clip-fullscreen-test.js | 90 | ||||
| -rw-r--r-- | tools/video-fallback-test.js | 6 | ||||
| -rw-r--r-- | tools/video-hls-play-test.js | 133 | ||||
| -rw-r--r-- | tools/vmedia-hls-test.js | 84 |
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); |
