diff options
Diffstat (limited to 'tools')
31 files changed, 2170 insertions, 99 deletions
diff --git a/tools/call-test.js b/tools/call-test.js new file mode 100644 index 0000000..d5c3909 --- /dev/null +++ b/tools/call-test.js @@ -0,0 +1,237 @@ +// 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', + '--auto-select-desktop-capture-source=Entire screen'] }); // a screen to share, chosen without asking +// 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)); + const w0 = wrapIds.size; + 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 })); + // the routes ride in the offer and answer: a household's two devices + // calling each other mustn't flood the relays (a per-IP limit ate the answer) + check('...in a handful of messages, not one per route', wrapIds.size - w0 <= 6, (wrapIds.size - w0) + ' wraps'); + 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 voice call turned video]'); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + const live = (p) => waitFor(p, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000); + check('a voice call connects, no cameras', await live(A.page) && await live(B.page) && !(await A.page.evaluate(() => !!document.querySelector('video.call-local')))); + check('...each side offered Turn on camera', await waitFor(A.page, () => !!document.querySelector('.call-add-video'), 3000) && await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000)); + await A.page.evaluate(() => document.querySelector('.call-add-video').click()); + check('Alice turns her camera on: she sees herself, and gets camera off and flip', await waitFor(A.page, () => !!document.querySelector('video.call-local')?.videoWidth && !document.querySelector('.call-add-video') + && !!document.querySelector('.call-btn[aria-label="Switch camera"]'), 10000)); + check('...Bob sees her', await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000), JSON.stringify(await media(B.page))); + check('...and can turn his on too', await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000)); + await B.page.evaluate(() => document.querySelector('.call-add-video').click()); + check('Bob turns his on: Alice sees him, both still talking', await waitFor(A.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000) + && (await media(A.page)).audio && (await media(B.page)).audio && await live(A.page), JSON.stringify(await media(A.page))); + + console.log('\n[sharing a screen]'); + const dims = (p) => p.evaluate(() => { const v = document.querySelector('video.call-remote'); return v ? v.videoWidth + 'x' + v.videoHeight : ''; }); + const camDims = await dims(B.page); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('Alice shares her screen: her preview shows it, the button says Stop sharing', await waitFor(A.page, () => !!document.querySelector('video.call-local.sharing') && !!document.querySelector('.call-share.on[aria-label="Stop sharing"]'), 10000)); + check('...Bob sees the screen in place of her camera', await waitFor(B.page, (d) => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && v.videoWidth + 'x' + v.videoHeight !== d; }, 15000, camDims), camDims + ' → ' + await dims(B.page)); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + const camOf = (p) => p.evaluate(() => { const v = document.querySelector('video.call-local'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; }); + check('stopping brings her camera back: it\'s her camera she sends, Bob still sees her', await waitFor(A.page, () => { const v = document.querySelector('video.call-local:not(.sharing)'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) + && await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000), await camOf(A.page) + ' | Bob ' + await dims(B.page)); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000); + await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + await live(A.page); await live(B.page); + await waitFor(A.page, () => !!document.querySelector('.call-share'), 3000); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('in a voice call, a shared screen shows on Bob\'s side', await waitFor(B.page, () => !!document.querySelector('.call-overlay.has-video') && document.querySelector('video.call-remote').videoWidth > 0, 15000)); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('...and goes when she stops (no camera to come back), the call carrying on', await waitFor(B.page, () => !document.querySelector('.call-overlay.has-video'), 15000) + && await live(B.page) && (await media(B.page)).audio); + check('...and her camera never came on', await A.page.evaluate(() => !document.querySelector('video.call-local'))); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000); + 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/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js index ff69626..4cbe68c 100644 --- a/tools/feed-picture-pager-test.js +++ b/tools/feed-picture-pager-test.js @@ -36,6 +36,7 @@ const seeded = [ const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round) 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); diff --git a/tools/go-live-test.js b/tools/go-live-test.js new file mode 100644 index 0000000..575f3ab --- /dev/null +++ b/tools/go-live-test.js @@ -0,0 +1,279 @@ +// 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/; +// LIVE_CONFIG=a json with {sfuAppId, sfuSecret} to watch over WebRTC — the real Cloudflare SFU) +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { naddrEncode } from 'nostr-tools/nip19'; +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 = 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, nameCreated = 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, created: nameCreated } : { taken: false }) }); +process.env.LIVE_DATA = dir; +process.env.NAMES_URL = names.url.origin; +process.env.LIVE_BASE = `http://localhost:${PORT}/live`; +const LIVE_PATH = process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js'; +let live = await import(LIVE_PATH); +// each socket stays with the server (module) that took it, so a "restart" +// (a fresh import) leaves the old connections to the old one +const sockMod = new WeakMap(); +const websocket = { + open: (ws) => { sockMod.set(ws, live); live.websocket.open?.(ws); }, + message: (ws, m) => (sockMod.get(ws) || live).websocket.message?.(ws, m), + close: (ws, c, r) => (sockMod.get(ws) || live).websocket.close?.(ws, c, r), +}; +const server = Bun.serve({ port: PORT, 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 {} + if (path === '/live/allowed') try { owner = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, ''))).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', + '--auto-select-desktop-capture-source=Entire screen', + // LATENCY=1: both pages keep full speed (a background tab's timers crawl) + ...(process.env.LATENCY ? ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'] : [])] }); +// LATENCY=1: the "camera" is a clock — the time in ms (24 bits) as black and +// white blocks — and the viewer reads it off the frame it shows: how far +// behind the broadcaster a watcher sees, end to end +const clockCam = () => { + const real = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + navigator.mediaDevices.getUserMedia = async (c) => { + if (!c || !c.video) return real(c); + const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720; + const x = cv.getContext('2d'); + setInterval(() => { + const v = Date.now() % 16777216; + x.fillStyle = '#808080'; x.fillRect(0, 0, 1280, 720); + for (let i = 0; i < 24; i++) { x.fillStyle = (v >> i) & 1 ? '#fff' : '#000'; x.fillRect(160 + (i % 6) * 160, 120 + Math.floor(i / 6) * 120, 160, 120); } + }, 1000 / 30); + const s = cv.captureStream(30); + if (c.audio) { const ac = new AudioContext(); const o = ac.createOscillator(); const d = ac.createMediaStreamDestination(); o.connect(d); o.start(); for (const t of d.stream.getAudioTracks()) s.addTrack(t); } + return s; + }; +}; +const readClock = () => { + const v = document.querySelector('video.live-video'); + if (!v || !v.videoWidth) return null; + const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight; + const x = cv.getContext('2d'); x.drawImage(v, 0, 0); + const k = v.videoWidth / 1280; + let n = 0; + for (let i = 0; i < 24; i++) { const d = x.getImageData(Math.round((240 + (i % 6) * 160) * k), Math.round((180 + Math.floor(i / 6) * 120) * k), 1, 1).data; if (d[0] + d[1] + d[2] > 384) n |= 1 << i; } + return ((Date.now() % 16777216) - n + 16777216) % 16777216; +}; + +const relayStub = (fx) => { + // what the relays held before a reload (a test can carry events across one) + try { fx = [...fx, ...JSON.parse(sessionStorage.getItem('__fx') || '[]')]; } catch {} + 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]))) { (window.__ingest = window.__ingest || []).push(ws); 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, []); + if (process.env.LATENCY) await page.evaluateOnNewDocument(clockCam); + // 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)); + // a name claimed an hour ago can't stream yet: the screen says when it can + nameCreated = Date.now() - 3600_000; + await page.evaluate(() => document.querySelector('.go-live-tool').click()); + check('a name claimed an hour ago is told it can go live a day after, Start off', await waitFor(() => !!document.querySelector('.go-live-refused') && /a day after you claimed it/.test(document.querySelector('.go-live-refused').textContent), 10000) + && await page.evaluate(() => document.querySelector('.go-live-start').disabled)); + await page.evaluate(() => document.querySelector('.live-page .chat-back, .live-page .iconbtn')?.click()); + nameCreated = Date.now() - 2 * 86400_000; + await waitFor(() => !document.querySelector('.go-live-start'), 5000); + if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + 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[camera ⇄ screen while live]'); + const sending = () => page.evaluate(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; }); + await page.evaluate(() => document.querySelector('.bc-switch').click()); + check('Share screen, live: the stream carries on with the screen', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && /^screen/.test(t.label) && t.readyState === 'live'; }, 10000) + && !!(await page.$('.bc-bar')) && await page.evaluate(() => document.querySelector('.bc-switch').textContent === 'Back to camera'), await sending()); + await page.evaluate(() => document.querySelector('.bc-switch').click()); + check('...and Back to camera returns to it, still live', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) && !!(await page.$('.bc-bar')), await sending()); + await sleep(4000); + const segsNow = ((await (await fetch(hls)).text()).match(/\.ts/g) || []).length; + check('...the server still making HLS through the switches', segsNow >= 3, segsNow + ' segments in the window'); + + console.log('\n[someone watching]'); + const viewer = await browser.newPage(); + if (process.env.LATENCY === '2') await viewer.evaluateOnNewDocument(() => { window.__ev = []; const add = HTMLMediaElement.prototype.addEventListener; for (const n of ['error', 'ratechange', 'waiting', 'playing', 'emptied', 'abort', 'stalled']) document.addEventListener(n, (e) => { const v = e.target; window.__ev.push([Math.round(performance.now()), n, v.playbackRate, v.error && v.error.code + ':' + v.error.message, v.currentTime.toFixed(1)]); }, true); }); + 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)); + const playsOk = await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000); + if (process.env.LATENCY === '2') console.log(' events:', JSON.stringify(await viewer.evaluate(() => window.__ev.slice(0, 30)))); + check('...and it plays', playsOk || 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 }; }))); + // LIVE_CONFIG with the SFU's key ({sfuAppId, sfuSecret}): the app's viewer + // watches over WebRTC through Cloudflare; without it, HLS + const overRtc = await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return !!x && x.srcObject instanceof MediaStream; }); + check(process.env.LIVE_CONFIG ? '...over WebRTC (Cloudflare\'s SFU)' : '...over HLS (no SFU key here)', overRtc === !!process.env.LIVE_CONFIG); + if (process.env.LATENCY) { + await viewer.evaluate(() => { window.__stalls = 0; document.querySelector('video.live-video').addEventListener('waiting', () => window.__stalls++); }); + const lags = []; + for (let i = 0; i < 30; i++) { const l = await viewer.evaluate(readClock); if (l != null && l < 60000) lags.push(l); await sleep(500); } + if (process.env.LATENCY === '2') console.log(' player:', JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); const r = (tr) => [...Array(tr.length).keys()].map((i) => tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1)); return { t: x.currentTime.toFixed(1), seekable: r(x.seekable), buffered: r(x.buffered), rate: x.playbackRate, dur: x.duration }; }))); + lags.sort((a, b) => a - b); + console.log(` behind the broadcaster: median ${(lags[lags.length >> 1] / 1000).toFixed(1)} s, min ${(lags[0] / 1000).toFixed(1)} s, max ${(lags.at(-1) / 1000).toFixed(1)} s (${lags.length} reads), ${await viewer.evaluate(() => window.__stalls)} stall(s)`); + } + 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 server restarts mid-stream]'); + // a fresh server, and the old one's shutdown: its streams end + live = await import(LIVE_PATH + '?restart=1'); + const oldId = tag(ann, 'd'); + const sj = `${dir}/${oldId}/stream.json`; + writeFileSync(sj, JSON.stringify({ ...JSON.parse(readFileSync(sj, 'utf8')), state: 'ended', ended: Date.now() })); + const before = (await lives(page)).length; + await page.evaluate(() => window.__ingest.forEach((w) => w.close())); + check('the app comes back as a new stream and announces it', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n) + .some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'live')), 40000, before)); + const after = (await lives(page)).slice(before); + const again = after.find((e) => tag(e, 'status') === 'live'); + check('...at its new address, not the dead one', again && tag(again, 'd') !== oldId && tag(again, 'streaming').includes(tag(again, 'd')), JSON.stringify(after.map((e) => [tag(e, 'd'), tag(e, 'status')]))); + const gone = after.find((e) => tag(e, 'd') === oldId); + check('...the old one said to be over, with no recording', gone && tag(gone, 'status') === 'ended' && !gone.tags.some((x) => x[0] === 'recording')); + const pl = await (await fetch(tag(again, 'streaming'))).text(); + check('...and the new one plays', (pl.match(/\.ts/g) || []).length >= 3, (pl.match(/\.ts/g) || []).length + ' segments'); + check('the streamer\'s own page follows to it', await waitFor((a) => !!document.querySelector('.live-page .bc-bar'), 5000)); + + 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'); + + console.log('\n[a stream the app lost]'); + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await waitText('receive'); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + if (!(await waitFor(() => !!document.querySelector('.feed-post-fab, .go-live-tool'), 15000))) console.log(' at:', await page.evaluate(() => location.pathname + ' ' + document.body.innerText.slice(0, 300).replace(/\s+/g, ' '))); + if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + await waitFor(() => !!document.querySelector('.go-live-tool'), 5000); + await page.evaluate(() => document.querySelector('.go-live-tool').click()); + await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0; }, 10000); + await page.type('.go-live-title', 'Lost stream'); + const n0 = (await lives(page)).length; + await page.evaluate(() => document.querySelector('.go-live-start').click()); + await waitFor((n) => window.__published.filter((e) => e.kind === 30311).length > n, 40000, n0); + const lost = (await lives(page)).at(-1); + // the tab goes (a reload): the relays still say live; the server waits a minute for the sender, then ends it + await page.evaluate((e) => sessionStorage.setItem('__fx', JSON.stringify([e])), lost); + await page.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: lost.pubkey, identifier: tag(lost, 'd') }), { waitUntil: 'domcontentloaded' }); + await sleep(64000); + check('opened, it says it\'s over (the server ended it), not live', await waitFor(() => !!document.querySelector('.live-page') && /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page .live-badge.live'), 30000), + await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200))); + check('...and its host gets End', await waitFor(() => !!document.querySelector('.live-end-stale'), 5000)); + const n1 = (await lives(page)).length; + await page.evaluate(() => document.querySelector('.live-end-stale').click()); + check('End says it ended', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n).some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'ended')), 10000, n1)); + const endedEv = (await lives(page)).at(-1); + check('...the same stream, with its recording', tag(endedEv, 'd') === tag(lost, 'd') && /index\.m3u8$/.test(tag(endedEv, 'recording') || ''), JSON.stringify(endedEv.tags)); + check('...and the End button goes', await waitFor(() => !document.querySelector('.live-end-stale'), 5000)); + 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/history-export-test.js b/tools/history-export-test.js new file mode 100644 index 0000000..f7fff82 --- /dev/null +++ b/tools/history-export-test.js @@ -0,0 +1,81 @@ +// Settings → Wallet → Export CSV: every payment in Spending (seeded Ark +// movements) as a line of a CSV file, oldest first, with what was paid, the +// fee beside it and the change to the balance; a failed payment is left out. +// Run: bun tools/history-export-test.js (NO_BUILD=1 to reuse dist/) +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.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 ns = (url) => { let h = 5381; for (const c of url) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; return h.toString(36); }; +const T0 = Date.UTC(2026, 9, 1, 12, 0, 0); +const movements = [ + { id: 'm1', type: 'ln-receive', status: 'complete', amountSat: 5000, invoice: 'lnbcrt50u1test', ts: T0 }, + { id: 'm2', type: 'ln-send', status: 'complete', amountSat: 1000, detail: 'fee 3 sat', paymentHash: 'ab'.repeat(32), invoice: 'lnbcrt10u1test', ts: T0 + 60_000 }, + { id: 'm3', type: 'send', status: 'complete', amountSat: 700, to: 'tark1someone', vtxoId: 'cd'.repeat(32) + ':0', ts: T0 + 120_000 }, + { id: 'm4', type: 'send', status: 'failed', amountSat: 999, detail: 'nope', ts: T0 + 180_000 }, + { id: 'm5', type: 'refresh', status: 'complete', amountSat: 3297, feeSat: 300, manual: true, txid: 'ef'.repeat(32), ts: T0 + 240_000 }, +]; + +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); +const server = Bun.serve({ port: 0, fetch: async (req) => { + const path = new URL(req.url).pathname; + 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'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +// the file the page hands the browser to save +await page.evaluateOnNewDocument(() => { + const make = URL.createObjectURL; + URL.createObjectURL = (b) => { b.text().then((x) => { window.__csv = x; window.__csvType = b.type; }); return make(b); }; + const click = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function () { if (this.download) { window.__csvName = this.download; return; } return click.call(this); }; +}); +const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, 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)) return true; await sleep(250); } return false; }; +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'); + // Spending's history, as the Ark manager stores it (moved into IndexedDB at the next boot) + const key = cacheKeyFor(mn + '\n') + ':ark:' + ns('http://localhost:3535'); + await page.evaluate(([k, v]) => localStorage.setItem(k, JSON.stringify(v)), [key, { v: 1, _rev: 999, vtxos: [], actions: [], movements, scheduled: [] }]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive'); + await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500); + await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(500); + check('Settings → Wallet has the export', await waitText('transaction history', 5000) && !!(await page.$('.history-export'))); + await page.evaluate(() => document.querySelector('.history-export').click()); + for (let i = 0; i < 20 && !(await page.evaluate(() => window.__csv)); i++) await sleep(100); + const { csv, name, type } = await page.evaluate(() => ({ csv: window.__csv, name: window.__csvName, type: window.__csvType })); + check('a .csv file is saved, named for today', /^coinos-history-\d{4}-\d{2}-\d{2}\.csv$/.test(name || '') && /text\/csv/.test(type || ''), name); + const rows = (csv || '').trim().split('\r\n').map((l) => l.split(',')); + console.log((csv || '').trim().split('\r\n').map((l) => ' ' + l).join('\n')); + const head = rows[0] || []; + const col = (r, n) => r[head.indexOf(n)]; + check('a header names the columns', head[0] === 'Date (UTC)' && head.includes('Net (sats)') && head.includes('Fee (sats)')); + const body = rows.slice(1); + check('...one line per payment, the failed one left out, oldest first', body.length === 4 && body.map((r) => col(r, 'Type')).join() === 'ln-receive,ln-send,send,refresh', body.map((r) => col(r, 'Type')).join()); + check('...dated in UTC', col(body[0], 'Date (UTC)') === '2026-10-01 12:00:00'); + check('a receive: + its amount, on Lightning, in Spending', col(body[0], 'Account') === 'Spending' && col(body[0], 'Rail') === 'lightning' && col(body[0], 'Net (sats)') === '5000'); + check('a Lightning payment: what was paid, its fee, both off the balance', col(body[1], 'Amount (sats)') === '-1000' && col(body[1], 'Fee (sats)') === '3' && col(body[1], 'Net (sats)') === '-1003' && col(body[1], 'Transaction / payment hash') === 'ab'.repeat(32)); + check('an Ark payment names who it went to', col(body[2], 'Net (sats)') === '-700' && col(body[2], 'To') === 'tark1someone' && col(body[2], 'Rail') === 'ark'); + check('a renewal costs its fee alone', col(body[3], 'Amount (sats)') === '0' && col(body[3], 'Net (sats)') === '-300'); + check('the lines add up to the balance they leave', body.reduce((s, r) => s + Number(col(r, 'Net (sats)')), 0) === 5000 - 1003 - 700 - 300); + 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✅ history exports as CSV' : '\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-chat-actions-test.js b/tools/live-chat-actions-test.js new file mode 100644 index 0000000..4e4d4ff --- /dev/null +++ b/tools/live-chat-actions-test.js @@ -0,0 +1,74 @@ +// Real live-chat controls with local relays and a fake payment handler. +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import {messagesFeature} from './src/features/messages.js'; +import {getPublicKey} from 'nostr-tools/pure'; +import {liveReactions, zapAddrOf} from './src/live.js'; +${dom} +const sk = new Uint8Array(32).fill(1), me = getPublicKey(sk), fan = '2'.repeat(64); +const ev = {id:'3'.repeat(64),pubkey:fan,kind:1311,created_at:1,content:'A viewer comment',tags:[]}; +const w = {a:'30311:'+'4'.repeat(64)+':stream',lines:new Map([[ev.id,ev]]),actions:new Map(),reacting:new Set(),relays:['wss://fixture'],stops:[],sheet:null}; +const ui = {}, calls = [], toasts = []; +window.__published=[]; window.__publishOK=true; +let feature; +const wallet = {mnemonic:'test',nostr:{pk:me,sk},nostrRelays:()=>[],loadFeatureState:()=>({}),saveFeatureState(){},registerCacheExtension(){}}; +function render(){if(feature) document.querySelector('#app').replaceChildren(feature.testLine(ev,w,liveReactions([...w.actions.values()])), ...(w.sheet?[feature.testSheet(w)]:[]));} +feature = messagesFeature({h,ui,wallet,render,toast:s=>toasts.push(s),zapDefaultSat:()=>21,zapSound:()=>false, + hook(name,...args){if(name==='arkReady')return true;if(name==='zapNpub'){calls.push(args);return true;}return null;}}); +feature.testStart(w,ev); render(); +window.test={ui,w,ev,me,fan,calls,toasts,render,addr:()=>zapAddrOf(ev.id),self:()=>{ev.pubkey=me;render();},other:()=>{ev.pubkey=fan;render();},settle:()=>feature.zapSettled(ev.id,true,21)}; +`; +const built = await Bun.build({entrypoints:['test-entry'],target:'browser',plugins:[{name:'test',setup(b){ + b.onResolve({filter:/^test-entry$/},()=>({path:'entry',namespace:'test'})); + b.onLoad({filter:/.*/,namespace:'test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); + b.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testLine: liveLine, testSheet: liveMessageSheet, testStart(w,e){liveWatch=w;setZapAddr(e.id,w.a);},")})); + b.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) + .replace(/export async function publishOn\(relays, evt, \{[^}]*\} = \{\}\) \{/, (m) => m + ' window.__published.push(evt); return window.__publishOK;') + .replace('export function subscribeOn(relays, filter, onEvent, extra = {}) {','export function subscribeOn(relays, filter, onEvent, extra = {}) { return () => {};') + .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') })); +}}]}); +assert(built.success,built.logs.join('\n')); +const html = '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>'+await Bun.file('src/style.css').text()+'</style><div id="app"></div><script type="module">'+await built.outputs[0].text()+'</script>'; +const server=Bun.serve({port:0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})}); +const browser=await puppeteer.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']}); +const page=await browser.newPage(), errors=[]; +page.on('pageerror',e=>errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort()); +const pause=ms=>new Promise(r=>setTimeout(r,ms)); +try { + await page.setViewport({width:390,height:844}); + await page.goto(server.url.href); + await page.waitForSelector('.chat-zap', {timeout:5000}).catch(async e => { throw new Error(errors.join(' | ') || e.message); }); + await page.click('.chat-zap'); + assert(await page.evaluate(()=>test.calls.length===1 && test.calls[0][0]===test.fan && test.calls[0][2]===test.ev.id && test.addr()===test.w.a),'zap must target the commenter, comment and stream'); + await page.evaluate(()=>{test.settle();test.self();}); + assert(!(await page.$('.chat-zap')),'no self-zap button'); + await page.evaluate(()=>test.other()); + await page.click('.chat-bubble'); + await page.waitForSelector('.msg-sheet-emojis'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForSelector('.chat-react.on:not(.zap-tally)'); + const reaction=await page.evaluate(()=>window.__published.find(e=>e.kind===7)); + assert(reaction.tags.some(t=>t[0]==='k'&&t[1]==='1311')); + await page.click('.chat-react.on:not(.zap-tally)'); await pause(100); + assert(!(await page.$('.chat-react.on:not(.zap-tally)')),'tapping again removes our reaction'); + await page.click('.chat-bubble'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForSelector('.chat-react.on:not(.zap-tally)'); + assert(await page.evaluate(()=>new Set(window.__published.filter(e=>e.kind===7).map(e=>e.id)).size===2),'immediately re-adding a removed reaction must have a new event id'); + await page.click('.chat-react.on:not(.zap-tally)'); await pause(100); + await page.evaluate(()=>window.__publishOK=false); + await page.click('.chat-bubble'); + await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click()); + await page.waitForFunction(() => !document.querySelector('.chat-react.on:not(.zap-tally)'), {timeout:3000}); + assert(await page.evaluate(()=>test.toasts.length>0)); + const box = await (await page.$('.chat-zap')).boundingBox(); + await page.mouse.move(box.x+box.width/2,box.y+box.height/2); await page.mouse.down(); await pause(650); await page.mouse.up(); + assert(await page.evaluate(()=>test.ui.zapSetup.pk===test.fan && test.ui.zapSetup.eventId===test.ev.id && test.ui.zapSetup.edit),'hold opens the commenter’s zap amount settings'); + assert.deepEqual(errors,[]); + console.log('✓ Live chat zap recipient/event/stream, no self-zaps, emoji actions, toggle/re-add and rejection rollback'); +} finally {await browser.close();server.stop(true);} diff --git a/tools/live-reactions-test.js b/tools/live-reactions-test.js new file mode 100644 index 0000000..b142528 --- /dev/null +++ b/tools/live-reactions-test.js @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { liveReactions } from '../src/live.js'; +const one = {kind:7,id:'one',pubkey:'alice',created_at:1,content:'❤️',tags:[['e','parent'],['e','comment']]}; +const two = {...one,id:'two',created_at:2,content:'👍'}; +const other = {...one,id:'other',pubkey:'bob'}; +const del = {kind:5,id:'delete',pubkey:'alice',created_at:3,tags:[['e','one'],['e','two']]}; +assert.equal(liveReactions([two, one]).get('comment').get('alice').content,'👍'); +assert.equal(liveReactions([one, one, other]).get('comment').size,2); +assert.equal(liveReactions([del, two, one, other]).get('comment').size,1); +assert.equal(liveReactions([{...del,pubkey:'mallory'},two]).get('comment').size,1); +assert.equal(liveReactions([one,two,{...del,tags:[['e','one']]}]).get('comment').get('alice').id,'two'); +assert.equal(liveReactions([two,{...two,id:'zzz'}]).get('comment').get('alice').id,'zzz'); +console.log('✓ Live reaction deduplication, replacement, out-of-order deletion and author validation'); diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js new file mode 100644 index 0000000..440be36 --- /dev/null +++ b/tools/live-stream-test.js @@ -0,0 +1,272 @@ +// 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); +// yesterday's stream, over, its recording kept +const past = finalizeEvent({ kind: 30311, created_at: now - 86000, content: '', tags: [ + ['d', 'stream-0'], ['title', 'Yesterday on coinos'], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM], + ['starts', String(now - 90000)], ['ends', String(now - 86000)], ['p', HOST.pk, '', 'Host'], +] }, SERVICE.sk); +// one kept on a video site: its "recording" is a web page +const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: '', tags: [ + ['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'], + ['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'], +] }, SERVICE.sk); +const reaction = finalizeEvent({kind:7,created_at:now-10,content:'👍',tags:[['e',chat.id],['p',FAN.pk],['k','1311']]},HOST.sk); +const messageZap = finalizeEvent({kind:9737,created_at:now-9,content:'nice comment',tags:[['e',chat.id],['p',FAN.pk],['P',HOST.pk],['amount','5'],['network','regtest']]},HOST.sk); +const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap]; +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, srv) => { + if (new URL(req.url).pathname === "/relay" && srv.upgrade(req)) return; + 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')); +}, websocket: { message(ws, data) { + const m = JSON.parse(String(data)); + if (m[0] === 'EVENT') ws.send(JSON.stringify(['OK', m[1].id, true, ''])); +} } }); +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('ws://localhost:5302/relay'); // local fixture relay; never publish test events outside the test + 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.some(e => e.id === m[1].id)) { window.__published.push(m[1]); setTimeout(() => window.__push(m[1]), 0); } + if (m[0] === 'CLOSE') for (let i = subs.length - 1; i >= 0; i--) if (subs[i][0] === ws && subs[i][1] === m[1]) subs.splice(i, 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('the Live chip sits after Videos, a red dot on it while a followed host streams', await waitFor(() => { + const modes = [...document.querySelectorAll('.media-mode')].map((b) => b.dataset.mode); + return modes.indexOf('live') === modes.indexOf('videos') + 1 && !!document.querySelector('.media-mode[data-mode="live"] .live-dot'); + }, 15000)); + check('...and no streams above the posts', !(await page.evaluate(() => document.querySelector('.live-grid, .live-strip')))); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click()); + check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 15000)); + const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid:not(.live-past) .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('past streams follow, the recording kept, its end time shown', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText) && /RECORDING/.test(t.innerText)), 8000) + && await page.evaluate(() => /Past streams/.test(document.body.innerText))); + check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream')))); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click()); + 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('.media-modes'), 5000); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click()); + await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 5000); + + console.log('\n[the stream\'s page]'); + await page.evaluate(() => document.querySelector('.live-grid:not(.live-past) .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) }; }))); + // a stream that's on plays through hls.js (kept near the live edge), not Chrome's own player + check('...through hls.js, kept near the live edge', await page.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:'))); + 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)); + const rowSel = '.live-message[data-key="' + chat.id + '"]'; + check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft')); + check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id)); + check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id)); + await page.click(rowSel + ' .chat-bubble'); + check('tapping the bubble opens the shared emoji/action sheet', await waitFor(() => !!document.querySelector('.msg-sheet-emojis'), 2000)); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find(b => b.textContent === '❤️').click()); + check('a reaction is published against the comment and its author', await waitFor(id => window.__published.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, chat.id)); + const myReaction = await page.evaluate(id => window.__published.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), chat.id); + check('reaction carries the stream and kind for other clients', myReaction?.tags.some(t => t[0] === 'a' && t[1] === A) && myReaction.tags.some(t => t[0] === 'p' && t[1] === FAN.pk) && myReaction.tags.some(t => t[0] === 'k' && t[1] === '1311')); + check('our reaction highlights immediately', await waitFor(id => !!document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id)); + await page.click(rowSel + ' .chat-react.on'); + check('tapping it again publishes its deletion', await waitFor(id => window.__published.some(e => e.kind === 5 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, myReaction.id)); + check('the removed reaction disappears', await waitFor(id => !document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id)); + const remoteReaction = finalizeEvent({kind:7,created_at:now+1,content:'🔥',tags:[['e',chat.id],['p',FAN.pk]]},HOST.sk); + await page.evaluate(e => window.__push(e), remoteReaction); + check('a new remote reaction updates without reloading', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('🔥'), 3000, chat.id)); + // The existing zap button must select this fan's npub, not the host. + const zapButton = await page.$(rowSel + ' .chat-zap'); + check('the comment has the same one-tap zap control as a DM', !!zapButton); + await page.click(rowSel + ' .chat-bubble'); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-item')].find(b => b.textContent.includes('Reply')).click()); + check('Reply opens the group-chat quote bar', await waitFor(() => !!document.querySelector('.live-page .reply-bar'), 2000)); + await page.focus('.live-page #msg-draft'); + 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('reply names the parent comment', !!sent && sent.tags.some(t => t[0] === 'e' && t[1] === chat.id) && sent.tags.some(t => t[0] === 'p' && t[1] === FAN.pk)); + check('the player keeps playing through the renders', await waitFor(playing, 3000)); + if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(400); await page.screenshot({ path: process.env.SHOT }); } + + console.log('\n[ways out]'); + await page.evaluate(() => document.querySelector('.live-page .chat-avatar.mini.live-ring').click()); + check('on the stream, the host\'s ringed face opens their profile (not the same stream again)', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-banner'), 8000)); + await page.goBack(); + await waitFor(() => !!document.querySelector('.live-page'), 5000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('Feed in the nav leaves the stream on the first tap', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000)); + await page.goBack(); + await waitFor(() => !!document.querySelector('.live-page'), 5000); + + 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('.media-mode[data-mode="all"]').click()); + await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000); + 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 lists their past streams', await waitFor(() => [...document.querySelectorAll('.live-past-row .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000)); + await page.evaluate(() => [...document.querySelectorAll('.live-past-row .live-tile')].find((t) => /Zelda on Rumble/.test(t.innerText)).click()); + check('a recording kept as a web page: ended, and a link to watch it there, no player', await waitFor(() => !!document.querySelector('.live-page a.live-rec-link[href="https://rumble.com/v7gt2va-zelda.html"]'), 5000) + && await page.evaluate(() => /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page video.live-video'))); + await page.goBack(); + await waitFor(() => !document.querySelector('.live-page'), 5000); + 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))); + await page.evaluate(() => [...document.querySelectorAll('.live-past-row .live-tile')].find((t) => /Yesterday on coinos/.test(t.innerText)).click()); + check('a past stream opens on its recording, playing', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000) + && await page.evaluate(() => !document.body.innerText.includes('This stream has ended'))); + 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/media-upload-identity-test.js b/tools/media-upload-identity-test.js new file mode 100644 index 0000000..6d7e023 --- /dev/null +++ b/tools/media-upload-identity-test.js @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from 'nostr-tools/pure'; +import { mediaUploadSigner, uploadPublicMedia, uploadStream, MediaUploadError } from '../src/media-upload.js'; + +const walletKey = generateSecretKey(), loginKey = generateSecretKey(); +let walletSigns = 0, loginSigns = 0, resumes = 0; +const wallet = { nostrSign: event => { walletSigns++; return finalizeEvent(event, walletKey); } }; +const signer = { signEvent(event) { loginSigns++; return finalizeEvent(event, loginKey); } }; +const login = {pubkey: getPublicKey(loginKey), signer}; +let id = login, resumed = signer; +const hook = name => name === 'nostrLoginIdentity' ? id : (resumes++, resumed); +const file = new File(['image'], 'test.png', {type: 'image/png'}); +const seen = []; +// Haven's owner/allowlist rule: only this login key can upload. +const fetcher = async (url, init) => { + const event = JSON.parse(atob(init.headers.authorization.slice(6))); + assert(verifyEvent(event)); + seen.push(event.pubkey); + if (event.pubkey !== login.pubkey) return new Response('', {status:403, headers:{'x-reason':'only media signed by whitelisted pubkeys are allowed'}}); + return Response.json({url:'https://haven.example/' + event.tags.find(t => t[0] === 'x')[1]}); +}; +const options = {servers:['https://haven.example'], fetcher}; +await assert.rejects(uploadPublicMedia(file, event => wallet.nostrSign(event), options), e => e instanceof MediaUploadError && e.code === 'unauthorized'); +const walletBefore = walletSigns; +await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options); +assert.equal(walletSigns, walletBefore); +assert.equal(loginSigns, 1); +assert.equal(resumes, 0); +id = {...login, signer:null}; +await uploadPublicMedia(file, await mediaUploadSigner(wallet, hook), options); +assert.equal(resumes, 1); +resumed = null; +await assert.rejects(mediaUploadSigner(wallet, hook), e => e.code === 'no-identity'); +assert.equal(walletSigns, walletBefore, 'an unavailable login signer must never fall back to the wallet'); +id = null; +const nativeEvent = await (await mediaUploadSigner(wallet, hook))({kind:24242, created_at:1, tags:[], content:''}); +assert.equal(nativeEvent.pubkey, getPublicKey(walletKey)); +id = login; +await uploadStream({master:'master.m3u8', files:new Map([ + ['clip.mp4', new File(['clip'], 'clip.mp4', {type:'video/mp4'})], + ['master.m3u8', new File(['#EXTM3U\nclip.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})], +])}, await mediaUploadSigner(wallet, hook), options); +assert(seen.slice(1).every(pk => pk === login.pubkey)); + +// Exercise the app's actual routing for both regular files and HLS videos. +const app = await Bun.file('src/app.js').text(); +const start = app.indexOf('async function publicMediaUpload(file) {'); +const end = app.indexOf('// Whether the wizard', start); +const body = app.slice(start, end).replace("await import('./video-compress.js')", 'video'); +const stream = {dim:'10x10',seconds:2}; +const signs = []; +const ui = {}; +const upload = new Function('wallet','featureHook','mediaUploadSigner','uploadPublicMedia','uploadStream','MediaUploadError','mediaProgress','ui','t','video', body + '\nreturn publicMediaUpload;')( + wallet, hook, mediaUploadSigner, + async (file, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'photo'; }, + async (stream, sign) => { signs.push(await sign({kind:24242, created_at:1, tags:[], content:''})); return 'video'; }, + MediaUploadError, () => {}, ui, key => key, + {streamVideo: async () => stream}); +assert.equal(await upload(file), 'photo'); +assert.equal((await upload(new File(['video'], 'clip.mp4', {type:'video/mp4'}))).url, 'video'); +assert(signs.every(e => e.pubkey === login.pubkey)); +assert.equal(ui.mediaProgress, null); +console.log('✓ Haven-style allowlist: login identity, signer resume, no wallet fallback, native identity and app file/HLS routing'); diff --git a/tools/names-invoice-expiry-test.js b/tools/names-invoice-expiry-test.js new file mode 100644 index 0000000..511d212 --- /dev/null +++ b/tools/names-invoice-expiry-test.js @@ -0,0 +1,70 @@ +// Receive → More → Lightning invoice: the invoice says to keep coinos open +// until it's paid (the server only finishes a receive while the wallet is +// there to take it) and gives way to "expired" when its hour is up. +// The Ark side is faked. Run: bun tools/names-invoice-expiry-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { namesFeature } from './src/features/names.js'; +${domHelper} +const state = { names: { byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } } }; +const ui = { screen: 'wallet', namesMore: 'bolt11' }; +const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => e, nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' }; +const ctx = { + h, ui, wallet, render, toast: () => {}, copy: () => {}, copyBtn: (x, l) => h('button', { class: 'btn-sm' }, l), brandHeader: () => null, goBack: () => {}, setAccount: () => {}, + hook(name, ...a) { + if (name === 'arkReady') return true; + if (name === 'arkMakeInvoice') return Promise.resolve({ invoice: 'lnbc10u1testinvoice', amountSat: a[0], expiresAt: Date.now() + (window.__life || 3600_000) }); + return null; + }, +}; +const feature = namesFeature(ctx); +function render() { document.querySelector('#app').replaceChildren(feature.receiveModes()[0].render(h('div', { class: 'seg' }))); } +window.test = { render }; +render(); +`; +const bundle = await Bun.build({ + entrypoints: ['names-inv-entry'], target: 'browser', + plugins: [{ name: 'names-inv', setup(build) { + build.onResolve({ filter: /^names-inv-entry$/ }, () => ({ path: 'entry', namespace: 'names-inv' })); + build.onLoad({ filter: /.*/, namespace: 'names-inv' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); +const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +const make = async () => { + await page.evaluate(() => { const i = document.querySelector('input[type=number]'); i.value = '1000'; i.dispatchEvent(new Event('input', { bubbles: true })); }); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /create/i.test(b.textContent)).click()); + await pause(100); +}; +try { + await page.goto(server.url.href); + await page.waitForSelector('input[type=number]'); + await make(); + check('the invoice shows', /lnbc10u1testinvoice/.test(await text())); + check('...saying to keep coinos open until it\'s paid, and that it lasts an hour', /Keep coinos open until this is paid/.test(await text()) && /goes back to the sender/.test(await text()) && /expires in an hour/.test(await text())); + await page.evaluate(() => { window.__life = 1500; document.querySelector('.btn-sm:last-child').click(); }); + await pause(50); + await page.evaluate(() => test.render()); + await make(); + check('a fresh one shows its QR', !!(await page.$('svg')) && !/expired/.test(await text())); + await pause(2300); + check('when its time is up it says expired, the QR gone', /This invoice has expired/.test(await text()) && !(await page.$('svg')) && !/lnbc10u1testinvoice/.test(await text())); + check('...with a button for a new one', await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'New invoice'))); + check('no page errors', !errors.length, errors.join(' | ')); + console.log('\n✅ invoice: keep open, expires'); +} finally { + await browser.close(); + server.stop(true); +} diff --git a/tools/notif-faces-test.js b/tools/notif-faces-test.js index b45b120..a1c1c8f 100644 --- a/tools/notif-faces-test.js +++ b/tools/notif-faces-test.js @@ -73,6 +73,8 @@ try { const ME = getPublicKey(privateKeyFromSeedWords(mn)); // a cached notifications list: five people reacted to my post const items = ACTORS.map((a, i) => ({ id: 'r'.repeat(63) + i, what: 'react', actor: a.pk, target: 't'.repeat(64), hint: [], emoji: '❤️', ts: now - 60 * (i + 1) })); + // and one zapped my profile: no post to open + items.unshift({ id: 'z'.repeat(64), what: 'zap', actor: ACTORS[1].pk, target: null, hint: [], sats: 421, text: 'thanks!', ts: now - 30 }); await page.evaluate(([k, me, items]) => { const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}'); st.notifs = items; st.notifsPk = me; st.notifsV = 2; @@ -100,6 +102,10 @@ try { await sleep(150); // the first paint, not a settled one const faces = await page.evaluate(() => [...document.querySelectorAll('.notes-feed .chat-avatar')].map((a) => a.classList.contains('ava-img') || (a.style.backgroundImage || '').includes('url'))); check('every row paints with a face on the first frame', faces.length >= 5 && faces.every(Boolean), JSON.stringify(faces)); + // a zap on my profile (no post): the tap opens the zapper's profile — it did nothing + await page.evaluate(() => [...document.querySelectorAll('.alert-row')].find((r) => r.textContent.includes('thanks!')).querySelector('.grow').click()); + await sleep(500); + check('tapping a profile zap opens the zapper\'s profile', await page.evaluate((pk) => !document.querySelector('.alert-row') && /npub1|nprofile1/.test(location.pathname) && document.body.innerText.includes('Actor 1'), ACTORS[1].pk), await page.evaluate(() => location.pathname)); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ notifications open with their faces on' : '\n❌ failed'); diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js index f88a074..6507607 100644 --- a/tools/pager-first-frame-test.js +++ b/tools/pager-first-frame-test.js @@ -2,8 +2,8 @@ // before the reader gets there: the server's (vmedia first.jpg — frame 0 at // full size, any host), put on the slide once fetched and decoded; where the // server has none, the next clip's frame is drawn here from its cached -// opening, and a clip beyond keeps the post's still rather than black, until -// a first frame comes. The slide left behind keeps its frame for the swipe +// opening, and a clip beyond gets no picture (and stays out of the +// carousel) until a first frame comes — never the post's still first. The slide left behind keeps its frame for the swipe // back. Run: bun tools/pager-first-frame-test.js import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; @@ -106,13 +106,15 @@ try { console.log('\n[the slides ahead hold their first frames]'); check('the next clip holds the server\'s first frame', await waitPoster('tall', 'server'), JSON.stringify(await slides())); check('…the server was asked for it once', asked.filter((x) => x === 'first:tall').length === 1, asked.join(' ')); - check('the clip after, which the server has no frame for, keeps the post\'s still', await waitPoster('last', 'still'), JSON.stringify(await slides())); + await sleep(1500); + check('the clip after, which the server has no frame for, gets no stand-in still', (await posterOf('last')) === 'none', JSON.stringify(await slides())); + check('…and waits out of the carousel', await page.evaluate(() => [...document.querySelectorAll('.mp-group')].find((g) => /last\.webm$/.test(g.querySelector('.mp-slide').getAttribute('data-k'))).classList.contains('mp-wait'))); check('…no thumbnail route was tried for any of them', !asked.some((x) => /thumb\.jpg/.test(x)), asked.join(' ')); await page.keyboard.press('ArrowDown'); check('on to the next: it plays', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm') && !s.querySelector('video').paused; }, null, 6000), JSON.stringify(await slides())); check('…the server\'s frame still under it', (await posterOf('tall')) === 'server', String(await posterOf('tall'))); check('the one left behind holds its first frame for the swipe back', await waitPoster('wide', 'server'), JSON.stringify(await slides())); - check('the next, now the clip ahead, has its frame drawn here in place of the still', await waitPoster('last', 'drawn', 12000), JSON.stringify(await slides())); + check('the next, now the clip ahead, has its frame drawn here ', await waitPoster('last', 'drawn', 12000), JSON.stringify(await slides())); check('…the server asked only once more for it, not again and again', asked.filter((x) => x === 'first:last').length === 1, asked.join(' ')); await page.keyboard.press('ArrowUp'); check('back on the first: it plays again, its frame under it', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/wide.webm') && !s.querySelector('video').paused && /first\.jpg/.test(s.querySelector('video').getAttribute('poster') || ''); }, null, 6000), JSON.stringify(await slides())); diff --git a/tools/pager-gone-picture-test.js b/tools/pager-gone-picture-test.js index 683493b..34c89b6 100644 --- a/tools/pager-gone-picture-test.js +++ b/tools/pager-gone-picture-test.js @@ -1,8 +1,7 @@ // A picture whose host is down (its server answers 502, so neither a copy -// nor the original can load) shows a plain "couldn't be loaded" slide with -// the host's name, not the browser's broken-picture glyph in a corner -// (Adam's screenshot, 2026-10-16: isolabellart's relay down). The slides -// around it are untouched. Run: bun tools/pager-gone-picture-test.js +// nor the original can load) never joins the pager's carousel: a picture +// joins once decoded (2026-10-17), and a step passes over it to the next. +// (Its slide is marked gone, with the host's name, as before — out of sight.) Run: bun tools/pager-gone-picture-test.js import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; @@ -30,6 +29,7 @@ const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60))]; const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round) 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); @@ -63,15 +63,10 @@ try { await page.mouse.click(at.x, at.y); check('the picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide img.mp-media'), null, 4000)); check('the picture that loads is sharpened at rest', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big1.png'); }, null, 5000)); + check('the dead picture\'s post never joins the carousel: both its copy and its original were tried', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/big2.png')); const g = s && s.closest('.mp-group'); return !!g && g.classList.contains('mp-wait') && s.classList.contains('mp-gone') && s.querySelector('img.mp-media').getAttribute('src').endsWith('/big2.png'); }, null, 8000)); await page.keyboard.press('ArrowDown'); - check('down: the dead picture\'s slide', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big2.png'); }, null, 4000)); - check('it says the picture couldn\'t be loaded, naming the host', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c.classList.contains('mp-gone') && /couldn’t be loaded/.test(c.textContent) && /localhost/.test(c.querySelector('.mp-missing')?.textContent || ''); }, null, 6000), await page.evaluate(() => document.querySelector('.mp-slide.mp-cur').outerHTML.slice(0, 300))); - check('…and the broken <img> is hidden', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur img.mp-media')).display === 'none')); - check('the original was tried after the copy failed', await page.evaluate(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big2.png'))); - await page.keyboard.press('ArrowDown'); - check('the next slide is fine', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big3.png') && !c.classList.contains('mp-gone'); }, null, 4000) && await waitFor(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big3.png'), null, 5000)); - check('its caption and controls are still there on the dead one', await page.evaluate(() => { const g = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/big2.png')); return !!g.querySelector('.mp-like') && /second big/.test(g.textContent); })); + check('down: past it, to the next picture', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big3.png') && !c.classList.contains('mp-gone'); }, null, 4000) && await waitFor(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big3.png'), null, 5000), await page.evaluate(() => document.querySelector('.mp-slide.mp-cur')?.getAttribute('data-k'))); 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 dead picture says so' : '\n❌ failed'); +console.log(ok ? '\n✅ a dead picture is passed over' : '\n❌ failed'); process.exit(ok ? 0 : 1); diff --git a/tools/pager-loading-test.js b/tools/pager-loading-test.js index 76e2764..42a5344 100644 --- a/tools/pager-loading-test.js +++ b/tools/pager-loading-test.js @@ -21,7 +21,7 @@ function fixture(kind = 'images', grid = false) { }; const src = { c: cache, more, done: () => false }; const ctx = vm.createContext({ - pager: p, pagerGroups: () => groups.slice(0, count), + pager: p, pagerGroups: () => groups.slice(0, count), pagerFound: () => groups.slice(0, count), pagerSource: () => grid ? null : src, feedNow: () => cache, mediaIndex: () => index, feedDef: () => ({}), crawlMedia: (c, k) => { assert.equal(c, cache); assert.equal(k, kind); return more(); }, growPager: () => {}, Date: { now: () => clock }, diff --git a/tools/pager-ready-gate-test.js b/tools/pager-ready-gate-test.js new file mode 100644 index 0000000..dd6982d --- /dev/null +++ b/tools/pager-ready-gate-test.js @@ -0,0 +1,153 @@ +// The video pager never lands on a black slide: a post joins the carousel +// only once its clip is ready to play (its first frame on the slide, its +// opening in the cache), the one tapped excepted; a post that joins above +// the clip on screen doesn't move it. And the carousel — like the Videos +// grid — shows the clips the screen's way round (tall ones on a phone held +// upright), a square either way, until the toggle asks for all shapes. +// Run: bun tools/pager-ready-gate-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { existsSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const jpg = (c, size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=' + c + ':s=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']).stdout; +const clip = (name, size) => { + const f = '/tmp/coinos-vpager-' + name + '.webm'; + if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]); + return Buffer.from(Bun.spawnSync(['cat', f]).stdout); +}; +// name → [shape, dims, first-frame delay ms] +const SHAPES = { tall1: ['180x320', 0], wide: ['320x180', 0], tall2: ['180x320', 3000], tall3: ['180x320', 0], sq: ['240x240', 0] }; +const CLIPS = Object.fromEntries(Object.entries(SHAPES).map(([n, [size]]) => [n, clip(size === '180x320' ? 'tall' : size === '320x180' ? 'wide' : 'last', size)])); +const FIRST = Object.fromEntries(Object.entries(SHAPES).map(([n, [size]]) => [n, jpg('green', size.split('x').map((x) => x * 4).join('x'))])); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => 'https://media.test/' + n + '.webm'; +const post = (n, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'dim ' + SHAPES[n][0]]], content: 'clip ' + n + ' ' + U(n) }, SK); +const seeded = ['tall1', 'wide', 'tall2', 'tall3', 'sq'].map((n, i) => post(n, 100 + i * 100)); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.setRequestInterception(true); +const asked = []; +page.on('request', (r) => { + const u = new URL(r.url()); + if (u.pathname === '/vmedia/first.jpg') { + const name = /\/(\w+)\.webm$/.exec(u.searchParams.get('u') || '')?.[1]; + asked.push('first:' + name); + const go = () => r.respond({ status: 200, contentType: 'image/jpeg', body: FIRST[name], headers: { 'access-control-allow-origin': '*' } }); + return SHAPES[name][1] ? void setTimeout(go, SHAPES[name][1]) : go(); + } + if (u.pathname.startsWith('/vmedia/')) return r.respond({ status: 404, body: 'no' }); + if (u.host === 'media.test') { + const name = /\/(\w+)\.webm$/.exec(u.pathname)[1], f = CLIPS[name]; + asked.push('clip:' + name); + const range = /^bytes=(\d+)-(\d*)$/.exec(r.headers().range || ''); + const cors = { 'access-control-allow-origin': '*', 'accept-ranges': 'bytes', 'access-control-expose-headers': 'content-range, content-length' }; + if (!range) return r.respond({ status: 200, contentType: 'video/webm', body: f, headers: cors }); + const a = +range[1], b = range[2] ? Math.min(+range[2], f.length - 1) : f.length - 1; + return r.respond({ status: 206, contentType: 'video/webm', body: f.subarray(a, b + 1), headers: { ...cors, 'content-range': 'bytes ' + a + '-' + b + '/' + f.length } }); + } + r.continue(); +}); +await page.evaluateOnNewDocument(() => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const groups = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group')].map((g) => { const s = g.querySelector('.mp-slide'), v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop().replace('.webm', ''), wait: g.classList.contains('mp-wait'), off: g.classList.contains('mp-off'), cur: s.classList.contains('mp-cur'), top: Math.round(r.top), poster: !!v.getAttribute('poster'), playing: !v.paused }; })); +const curClip = async () => (await groups()).find((g) => g.cur)?.clip; +const inCarousel = async () => (await groups()).filter((g) => !g.wait && !g.off).map((g) => g.clip).join(' '); +const counter = () => page.evaluate(() => document.querySelector('.mp-position').textContent); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 3, null, 20000)); + + console.log('\n[the Videos grid: the clips the screen\'s way round]'); + await click('videos'); + check('the grid shows the tall clips and the square one, not the wide one', await waitFor(() => { const t = [...document.querySelectorAll('.media-tile.video')].map((x) => x.getAttribute('data-key')); return t.length === 4 && !t.some((k) => /wide/.test(k)); }, null, 8000), String(await page.evaluate(() => document.querySelectorAll('.media-tile.video').length))); + check('…with no toggle over it (that lives in the pager)', await page.evaluate(() => !document.querySelector('.media-fit-btn, .media-grid .mp-fit'))); + + console.log('\n[the carousel: a post joins once its clip is ready to play]'); + await page.evaluate(() => [...document.querySelectorAll('.media-tile.video')].find((x) => /tall1/.test(x.getAttribute('data-key'))).click()); + check('the pager opens on the clip tapped', await waitFor(() => { const s = document.querySelector('.media-pager.videos .mp-slide.mp-cur'); return !!s && /tall1/.test(s.getAttribute('data-k')); }, null, 6000)); + check('…and it plays', await waitFor(() => !document.querySelector('.mp-slide.mp-cur video').paused, null, 8000)); + check('the wide clip is not in this carousel at all (the grid\'s list is the screen\'s way round)', !(await groups()).some((g) => g.clip === 'wide'), JSON.stringify(await groups())); + check('the next tall clip, its first frame still on its way, waits out of the carousel', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall2/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && g.classList.contains('mp-wait'); }, null, 1000), await inCarousel()); + check('the one after, ready, has joined', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall3/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-wait') && !!g.querySelector('video').getAttribute('poster'); }, null, 8000), await inCarousel()); + const before = await inCarousel(); + await page.keyboard.press('ArrowDown'); + check('a step down lands on it, past the one not ready', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall3/.test(s.getAttribute('data-k')); }, null, 4000), (await curClip()) + ' (carousel was: ' + before + ')'); + check('…never a black slide: the clip landed on had its frame', (await groups()).find((g) => g.clip === 'tall3')?.poster === true); + const n0 = await counter(); + check('the waiting post joins once its frame comes', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /tall2/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-wait'); }, null, 8000), await inCarousel()); + await sleep(300); + const g = await groups(); + check('…joining above the clip on screen without moving it', g.find((x) => x.cur)?.clip === 'tall3' && Math.abs(g.find((x) => x.cur).top) <= 1, JSON.stringify(g.map((x) => x.clip + ':' + x.top + (x.cur ? '*' : '')))); + check('…and the counter grew', (await counter()) !== n0, n0 + ' → ' + (await counter())); + await page.keyboard.press('ArrowUp'); + check('a step back up lands on the one that joined', await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall2/.test(s.getAttribute('data-k')) && !s.querySelector('video').paused; }, null, 6000), String(await curClip())); + + console.log('\n[no flying with fewer than ten clips ready]'); + await sleep(500); + const ready = (await inCarousel()).split(' ').length; + // a hard flick (600px in 80ms): a flight's worth, were flying allowed + await page.evaluate(() => { + const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el; + const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y }); + const fire = (type, y, up) => target.dispatchEvent(new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] })); + const t0 = performance.now(); + fire('touchstart', 700); + for (let i = 1; i <= 3; i++) { while (performance.now() < t0 + 80 * i / 3); fire('touchmove', 700 - 600 * i / 3); } + fire('touchend', 100, true); + }); + check('with ' + ready + ' clips ready and none still warming, the counter shows (that is all there is)', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'visible')); + check('with ' + ready + ' clips ready, a hard flick from tall2 goes one post, to tall3', ready < 10 && await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && /tall3/.test(s.getAttribute('data-k')); }, null, 4000), String(await curClip())); + await page.keyboard.press('ArrowUp'); + await waitFor(() => /tall2/.test(document.querySelector('.mp-slide.mp-cur')?.getAttribute('data-k') || ''), null, 4000); + + console.log('\n[the toggle in the pager]'); + check('the pager\'s toggle says tall only', (await page.evaluate(() => document.querySelector('.mp-fit')?.textContent)) === 'Tall only'); + await page.evaluate(() => document.querySelector('.mp-fit').click()); + check('tapped: the wide clip\'s post is built (it joins once ready)', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /wide/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && !g.classList.contains('mp-off'); }, null, 4000), await inCarousel()); + check('…the clip on screen stayed put', (await curClip()) === 'tall2' && Math.abs((await groups()).find((x) => x.cur).top) <= 1, JSON.stringify((await groups()).map((x) => x.clip + ':' + x.top + (x.cur ? '*' : '')))); + await page.evaluate(() => document.querySelector('.mp-fit').click()); + check('tapped again: kept out for its shape', await waitFor(() => { const g = [...document.querySelectorAll('.mp-scroll > .mp-group')].find((x) => /wide/.test(x.querySelector('.mp-slide').getAttribute('data-k'))); return g && g.classList.contains('mp-off'); }, null, 2000)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ posts join the carousel ready to play, the screen\'s way round' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js index 6c073c6..64ff7b5 100644 --- a/tools/pager-seek-hide-test.js +++ b/tools/pager-seek-hide-test.js @@ -2,7 +2,9 @@ // while the pager is in motion — a glide to the next clip, a finger // dragging the slides, the snap back — no seek bar shows at all (it rode // along with the slide, a white line sliding up the screen), and it fades -// back in once the clip has landed. Run: bun tools/pager-seek-hide-test.js +// back in once the clip has landed. The position counter is the other way +// round: it shows while the pager moves and fades out once it has settled. +// Run: bun tools/pager-seek-hide-test.js import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; @@ -56,6 +58,7 @@ const seenBars = async () => (await bars()).filter((x) => x.seen).map((x) => x.c // a recorder, frame by frame: the scroll position, the pager's motion flag, and any bar that can be seen const record = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'), el = document.querySelector('.media-pager'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push({ top: sc.scrollTop, moving: el.classList.contains('moving'), seen: [...document.querySelectorAll('.mp-seek')].filter((b) => { const cs = getComputedStyle(b); return cs.visibility === 'visible' && +cs.opacity > 0.01; }).length }); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); }); const trace = () => page.evaluate(() => window.__tr); +const counterOpacity = () => page.evaluate(() => +getComputedStyle(document.querySelector('.mp-position')).opacity); try { await page.setViewport({ width: 390, height: 844, hasTouch: true }); @@ -90,6 +93,7 @@ try { check('…and its seek bar shows', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && b.classList.contains('ready') && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars())); check('…no other', (await seenBars()).length === 1, (await seenBars()).join(' ')); check('the pager is not marked moving', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving'))); + check('the position counter has faded out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity())); console.log('\n[a glide to the next clip: no seek bar on the way]'); await record(); @@ -112,9 +116,13 @@ try { await page.touchscreen.touchMove(195, 500); await sleep(250); check('held 100px up, the pager is marked moving', await page.evaluate(() => document.querySelector('.media-pager').classList.contains('moving'))); check('…and no seek bar shows', (await seenBars()).length === 0, (await seenBars()).join(' ')); + check('…and the position counter is back', (await counterOpacity()) === 1, String(await counterOpacity())); await page.touchscreen.touchMove(195, 590); await sleep(30); await page.touchscreen.touchEnd(); check('let go, it snaps back and the bar returns', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && getComputedStyle(b).opacity === '1' && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars())); + await sleep(500); + check('a moment after, the counter still shows', (await counterOpacity()) > 0.99, String(await counterOpacity())); + check('…then fades out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity())); 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✅ no seek bar while the pager moves; it fades in on landing' : '\n❌ failed'); diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js index d809793..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'; @@ -11,7 +13,7 @@ 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 png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; -const N = 9, PIC = png('360x360'); +const N = 12, PIC = png('360x360'); const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); const now = Math.floor(Date.now() / 1000); const html = await buildHtml({ minify: true, pwa: false }); @@ -26,6 +28,7 @@ const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, crea const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round) 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); @@ -33,7 +36,7 @@ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; // the post the pager rests square on (its number), or 'between' const at = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 1 && Math.abs(r.left) < 1; }); return s ? Number(/p(\d+)\.png/.exec(s.getAttribute('data-k'))[1]) : 'between'; }); -const settled = async () => { let last = null; for (let i = 0; i < 40; i++) { const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } }; +const settled = async () => { let last = null; for (let i = 0; i < 100; i++) { /* (a long flight takes up to 4 s) */ const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } }; // a swipe up the screen: px of travel over about `ms` milliseconds. A // quick one is dispatched inside the page, as fast as the handlers take // the moves (about 30–50 ms each here, headless): the browser's own touch @@ -58,6 +61,23 @@ const swipe = async (px, ms, steps = 6) => { fire('touchend', 700 - px, true); }, [px, ms, steps]); }; +// A swipe whose moves are made on schedule (each event stamped as the finger +// moved) but handled late, the main thread busy: held back and delivered in +// a burst at the end (burst), or the last move and the lift after a stall. +const queued = (px, ms, steps, mode) => page.evaluate(([px, ms, steps, mode]) => { + const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el; + const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y }); + const make = (type, y, up) => new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] }); + const t0 = performance.now(), evs = [make('touchstart', 700)]; + for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); evs.push(make('touchmove', 700 - px * i / steps)); } + evs.push(make('touchend', 700 - px, true)); + if (mode === 'burst') { for (const e of evs) target.dispatchEvent(e); return; } + // stall: all but the last move handled as they come, then 300 ms busy + const tail = evs.splice(-2); + for (const e of evs) target.dispatchEvent(e); + const t1 = performance.now(); while (performance.now() < t1 + 300); + for (const e of tail) target.dispatchEvent(e); +}, [px, ms, steps, mode]); try { await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: true }); await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); @@ -83,7 +103,8 @@ try { const pic = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture post 1 ')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }); await page.touchscreen.tap(pic.x, pic.y); check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && (await (async () => { await settled(); return at(); })()) === 1, String(await at())); - check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 8000)); + check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 15000)); + check('…and decoded, in the carousel (ten ready before it flies)', await waitFor((n) => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= n, N, 10000), String(await page.evaluate(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length))); console.log('\n[a gentle swipe: one post]'); await swipe(320, 130); await settled(); @@ -91,33 +112,73 @@ try { console.log('\n[a fast flick: several posts, landing snapped]'); const trace = async (sw) => { await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push(sc.scrollTop); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); }); await sw(); await sleep(1600); return page.evaluate(() => window.__tr); }; + await page.evaluate(() => { const el = document.querySelector('.mp-position'); window.__counts = [el.textContent]; new MutationObserver(() => { if (window.__counts.at(-1) !== el.textContent) window.__counts.push(el.textContent); }).observe(el, { childList: true, characterData: true, subtree: true }); }); const tr = await trace(() => swipe(600, 80, 3)); await settled(); // three big moves: 3–6 px/ms as their handling takes, a long coast's worth (past the nine loaded, maybe) const landed = await at(); check('a 600px flick in a few frames flies several posts on', landed !== 'between' && landed >= 5, String(landed)); check('…landing square on a post', landed !== 'between'); check('…in a glide, never back', tr.every((x, i) => !i || x >= tr[i - 1] - 0.5), tr.slice(0, 12).map((x) => Math.round(x)).join(' ')); + check('the counter ran through the posts on the way, not jumping on landing', (await page.evaluate(() => window.__counts)).length >= 4, (await page.evaluate(() => window.__counts)).join(' → ')); 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: the last post, not past it]'); + 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 lands on the last post', before >= 7 && (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('…and one more flick at the end wraps to the first', (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())); + + console.log('\n[a busy main thread: the speed is the finger\'s, not the handling\'s]'); + // (the dispatch loop runs all the events in one go: built on schedule, each stamped as it was made) + while ((await at()) !== 1) { await page.keyboard.press('ArrowUp'); await settled(); } + await queued(200, 200, 8, 'burst'); await settled(); + check('from post 1, a gentle 200px swipe over 200ms, its moves handled in one burst: one post on', (await at()) === 2, String(await at())); + await queued(600, 80, 4, 'stall'); await settled(); + 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 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 finger down mid-flight catches it]'); + 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/pager-tap-slop-test.js b/tools/pager-tap-slop-test.js index 09f761a..4de87df 100644 --- a/tools/pager-tap-slop-test.js +++ b/tools/pager-tap-slop-test.js @@ -76,6 +76,7 @@ try { localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); localStorage.setItem('btc-wallet-feed:' + k, 'following'); + localStorage.setItem('coinos-clips-fit', 'all'); // (the wide clip stays in: this test is about the finger, not the shapes) }, [base, AUTHOR, seeded]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); diff --git a/tools/picture-pager-ready-test.js b/tools/picture-pager-ready-test.js new file mode 100644 index 0000000..7cfaa45 --- /dev/null +++ b/tools/picture-pager-ready-test.js @@ -0,0 +1,102 @@ +// The picture pager works like the video pager (Adam, 2026-10-17: "image +// pager should work similar. we need the tall only chip there too"): a +// picture post joins the carousel once its picture is decoded, so a step +// passes over one still on its way; the shapes the wrong way round for +// the screen stay out, with the "Tall only" / "All shapes" toggle in the +// top bar; and with fewer than ten ready and more still warming, no +// counter shows and a flick goes one post. Run: bun tools/picture-pager-ready-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'; +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 png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +// t1..t4 tall, w1 wide, sq square; slow (tall) is held back until released +const PICS = { t1: png('300x400'), t2: png('300x420'), slow: png('300x440'), t3: png('300x460'), w1: png('480x270'), sq: png('320x320'), t4: png('300x480') }; +let release; const held = new Promise((r) => (release = r)); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: async (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(\w+)\.png$/.exec(path); + if (m && PICS[m[1]]) { if (m[1] === 'slow') await held; return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } }); } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const ORDER = ['t1', 'slow', 't2', 'w1', 'sq', 't3', 't4']; +const seeded = ORDER.map((n, i) => post('picture ' + n + ' ' + U(n), 100 + i * 100)); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +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, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const name = (k) => k.split('|')[1].split('/').pop().replace('.png', ''); +const carousel = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' ')); +const cur = () => page.evaluate(() => { const s = document.querySelector('.mp-slide.mp-cur'); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : null; }); +const resting = (n) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 2 && Math.abs(r.left) < 2; }); return !!s && s.getAttribute('data-k').endsWith('/' + n + '.png'); }, n, 5000); + +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: 'Picture 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 posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 7, null, 20000)); + await waitFor(() => !!document.querySelector('.notes-feed img.note-img'), null, 10000); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); r.scrollIntoView({ block: 'center' }); }); + await sleep(400); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }); + await page.mouse.click(at.x, at.y); + check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide'), null, 4000) && await resting('t1'), await cur()); + + console.log('\n[a picture joins once decoded]'); + check('the pictures decoded join: t2, t3, t4 and the square', await waitFor(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= 5, null, 8000), await carousel()); + check('the one still on its way waits out of the carousel', !(await carousel()).includes('slow'), await carousel()); + check('the wide one is out for its shape', !(await carousel()).includes('w1'), await carousel()); + check('with fewer than ten ready and one still warming, no counter shows', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'hidden')); + await page.keyboard.press('ArrowDown'); + check('down from t1 passes over it, to t2', await resting('t2'), await cur()); + release(); + check('once it has come, it joins, above the one on screen', await waitFor(() => /t1 slow t2/.test([...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' ')), null, 8000), await carousel()); + check('…without moving it', await resting('t2'), await cur()); + check('everything in, the counter shows: 3 / 6', await waitFor(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'visible' && document.querySelector('.mp-position').textContent === '3 / 6', null, 3000), await page.evaluate(() => document.querySelector('.mp-position').textContent)); + await page.keyboard.press('ArrowUp'); + check('up lands on it', await resting('slow'), await cur()); + + console.log('\n[the shape toggle in the picture pager]'); + const label = await page.evaluate(() => document.querySelector('.media-pager .mp-fit')?.textContent); + check('the top bar has the toggle, saying tall only', label === 'Tall only', String(label)); + check('…as tall as the close button', await page.evaluate(() => document.querySelector('.mp-fit').offsetHeight === document.querySelector('.mp-x').offsetHeight)); + await page.evaluate(() => document.querySelector('.mp-fit').click()); + check('tapped: the wide picture joins', await waitFor(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].some((g) => /w1\.png$/.test(g.querySelector('.mp-slide').getAttribute('data-k'))), null, 6000), await carousel()); + check('…and the toggle says all shapes', (await page.evaluate(() => document.querySelector('.mp-fit').textContent)) === 'All shapes'); + check('…the picture on screen stayed put', await resting('slow'), await cur()); + await page.evaluate(() => document.querySelector('.mp-fit').click()); + check('tapped again: the wide one is out', await waitFor(() => !document.querySelector('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off) .mp-slide[data-k$="/w1.png"]'), null, 3000), await carousel()); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { release(); await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the picture pager: decoded pictures only, the screen\'s way round, with the toggle' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js index 29341e7..2a78598 100644 --- a/tools/profile-pager-test.js +++ b/tools/profile-pager-test.js @@ -36,6 +36,7 @@ const seeded = [ const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round) 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); @@ -104,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/quote-pager-test.js b/tools/quote-pager-test.js index 5d9e536..7f8b76c 100644 --- a/tools/quote-pager-test.js +++ b/tools/quote-pager-test.js @@ -39,6 +39,7 @@ const ALL = [...FEED, quotedPost]; const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { try { localStorage.setItem('coinos-clips-fit', 'all'); } catch {} }); // (every shape: this test isn't about the screen's way round) await page.evaluateOnNewDocument((evs) => { 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.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since) && !Object.keys(f).some((k) => k.startsWith('#')); 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); |
