diff options
Diffstat (limited to 'tools')
43 files changed, 3409 insertions, 194 deletions
diff --git a/tools/ark/bg-renewal-test.js b/tools/ark/bg-renewal-test.js new file mode 100644 index 0000000..0a85816 --- /dev/null +++ b/tools/ark/bg-renewal-test.js @@ -0,0 +1,135 @@ +// The service worker tends a closed wallet's renewals, on regtest, end to end: +// +// the app receives a coin and writes the background mirror -> "closes" -> +// the worker (tendRenewals over the mirror, keys from the mirror alone) +// registers a standing order for the coin -> the worker SPENDS from the +// mirror (an NWC pay) which voids that order; the next wake covers the +// change coin -> the scheduled height passes, the server runs the round +// with nobody online -> the next wake claims the renewed coin into the +// mirror -> the app returns and merges the worker's state: fresh coin, +// old one spent, the order gone. +// +// Usage: bun tools/ark/bg-renewal-test.js +// Needs the ark-regtest stack (bc, ark-electrs, captaind on :3535). + +import { execSync } from 'node:child_process'; +import { hex } from '@scure/base'; +import { generateMnemonic, mnemonicToSeedSync } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { HDKey } from '@scure/bip32'; + +import { ArkManager } from '../../src/ark/manager.js'; +import { buildBg } from '../../src/nwc-bg.js'; +import { tendRenewals, bgManager } from '../../src/nwc-respond.js'; +import { mergeArkStates } from '../../src/features/ark.js'; +import { ALICE, aliceReady } from './alice.js'; + +const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535'; +const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002'; +const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); +const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`); +const tipHeight = () => Number(sh(`${BCLI} getblockcount`)); + +let ok = true; +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!cond) ok = false; +}; + +// --- the app's wallet --- +const store = { raw: null }; +const storage = { load: () => (store.raw ? JSON.parse(store.raw) : null), save: (s) => { store.raw = JSON.stringify(s); } }; +const mnemonic = generateMnemonic(wordlist); +const account = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive("m/86'/0'/9'"); +const app = await new ArkManager({ account, storage, arkUrl: ARK, esploraUrl: ESPLORA }).init(); +await aliceReady(); + +const receive = async (sat) => { + const before = app.balance().spendableSat; + sh(`${ALICE} -q send ${app.address()} "${sat} sat"`); + for (let i = 0; i < 15 && app.balance().spendableSat < before + sat; i++) { await app.sync(); await sleep(1000); } + return app.balance().spendableSat - before; +}; + +// --- the mirror the worker answers from (what writeBgMirror writes) --- +let rec = null; +const writeMirror = () => { + rec = buildBg({ + ark: { arkUrl: ARK, esploraUrl: ESPLORA, network: 'regtest', serverPubkey: app.info.serverPubkey }, + mgrState: JSON.parse(JSON.stringify(app.state)), + keyFor: (chain, i) => hex.encode(account.deriveChild(chain).deriveChild(i).privateKey), + connections: [], + }); +}; +const recs = async () => [{ walletKey: 'w1', rec }]; +const saveFn = async (k, r) => { rec = r; }; +const log = (m) => console.log(' [sw-renew] ' + m); +const tend = (margin) => tendRenewals({ recordsFn: recs, saveFn, log, deadline: Date.now() + 60_000, margin }); +const mirrorCoins = () => (rec.mgr.vtxos || []).filter((v) => v.state === 'spendable'); + +console.log('\n[1] the app receives a coin, mirrors it, and closes'); +check('received 20000 sat', (await receive(20000)) === 20000, JSON.stringify(app.balance())); +writeMirror(); +const coin = mirrorCoins()[0]; +check('the mirror holds the coin and its key', !!coin && !!rec.keys[String(coin.keyIndex)] && !!rec.key4); +check('no standing order yet, on either side', !(app.state.scheduled || []).length && !(rec.mgr.scheduled || []).length); + +console.log('\n[2] a wake: the worker registers a standing order from the mirror alone'); +// aim the order a few blocks out so the test needn't mine a month +const n1 = await tend(coin.expiryHeight - (tipHeight() + 3)); +const order1 = (rec.mgr.scheduled || [])[0]; +check('one order registered', n1 === 1 && (rec.mgr.scheduled || []).length === 1); +check('with the server\'s unlock hash', !!order1?.unlockHash, order1?.unlockHash?.slice(0, 16)); +check('the coin stays spendable in the mirror', mirrorCoins().some((v) => v.id === coin.id)); +check('the output key it burned is in the mirror', !!rec.keys[String(order1?.outKeyIndex)]); +check('the app knows nothing of it yet', !(app.state.scheduled || []).length); +check('a second wake registers nothing more', (await tend(coin.expiryHeight - (tipHeight() + 3))) === 0 && rec.mgr.scheduled.length === 1); + +console.log('\n[3] the worker spends from the mirror (an NWC pay): the order dies, the change gets its own'); +const worker = await bgManager(rec, 'w1', saveFn); +await worker.send(sh(`${ALICE} -q address`), 5000); +await worker.sync(); +const change = mirrorCoins().find((v) => v.id !== coin.id); +check('change coin in the mirror', !!change, change && `${change.amountSat} sat`); +check('the spent coin\'s order is gone', !(rec.mgr.scheduled || []).some((s) => s.inputIds.includes(coin.id))); +const n2 = await tend(change.expiryHeight - (tipHeight() + 3)); +const order2 = (rec.mgr.scheduled || []).find((s) => s.inputIds.includes(change.id)); +check('the next wake covers the change coin', n2 === 1 && !!order2, JSON.stringify((rec.mgr.scheduled || []).map((s) => s.inputIds))); +check('scheduled a few blocks out', order2?.scheduledHeight === tipHeight() + 3, `${order2?.scheduledHeight} vs ${tipHeight() + 3}`); + +console.log('\n[4] the round runs with nobody online; the next wake claims it into the mirror'); +mine(3 + 2); +await sleep(15_000); +const claimed = () => (rec.mgr.actions || []).some((a) => a.type === 'refresh' && a.step === 'done'); +for (let i = 0; i < 25 && !claimed(); i++) { await tend(change.expiryHeight - (tipHeight() + 3)); mine(1); await sleep(1500); } +if (!claimed()) console.log(' actions:', JSON.stringify((rec.mgr.actions || []).map((a) => [a.type, a.step, a.lastError]))); +const renewed = mirrorCoins().find((v) => v.id !== change.id && v.id !== coin.id); +const renewedId = mirrorCoins().find((v) => v.id !== change.id && v.id !== coin.id)?.id; +check('the consumed order is replaced by one for the fresh coin, in the same wake', (rec.mgr.scheduled || []).length === 1 && !!renewedId && rec.mgr.scheduled[0].inputIds.includes(renewedId), JSON.stringify((rec.mgr.scheduled || []).map((s) => s.inputIds))); +check('the change coin is spent', rec.mgr.vtxos.find((v) => v.id === change.id)?.state === 'spent'); +check('a fresh coin sits in the mirror', !!renewed, renewed && `${renewed.amountSat} sat`); +check('worth the change minus the scheduled fee', !!renewed && renewed.amountSat === change.amountSat - order2.feeSat, `${renewed?.amountSat} vs ${change?.amountSat} - ${order2?.feeSat}`); +check('with its expiry clock restarted', !!renewed && renewed.expiryHeight > change.expiryHeight, renewed && `${change.expiryHeight} -> ${renewed.expiryHeight}`); +check('the renewal is in the mirror\'s history', (rec.mgr.movements || []).some((m) => m.type === 'refresh' && m.status === 'complete')); + +console.log('\n[5] the app returns and merges the worker\'s state'); +// what mergeBgWorkerState does on connect: additive merge, the worker's spent flips win, reconcile +const merged = mergeArkStates(app.state, rec.mgr); +const workerState = new Map(rec.mgr.vtxos.map((v) => [v.id, v.state])); +for (const v of merged.vtxos) if (workerState.get(v.id) === 'spent') v.state = 'spent'; +merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); +app.state = merged; app._save(); +await app.reconcile().catch((e) => console.log(' reconcile:', e.message)); +await app.sync(); +const appCoins = app.vtxos().filter((v) => v.state === 'spendable'); +check('the app holds the fresh coin and nothing stale', appCoins.length === 1 && appCoins[0].id === renewed?.id, JSON.stringify(appCoins.map((v) => [v.id.slice(0, 8), v.amountSat, v.state]))); +check('balance is the change minus the renewal fee', app.balance().spendableSat === change.amountSat - order2.feeSat, JSON.stringify(app.balance())); +check('the app adopts the worker\'s order for the fresh coin', (app.state.scheduled || []).length === 1 && app.state.scheduled[0].inputIds.includes(renewed?.id) && app.state.scheduled[0].unlockHash === rec.mgr.scheduled[0].unlockHash, JSON.stringify((app.state.scheduled || []).map((s) => s.inputIds))); +check('the app is not busy', app.pendingActions().length === 0, JSON.stringify(app.pendingActions().map((a) => [a.type, a.step]))); +check('the renewal shows in the app\'s history once', app.movements().filter((m) => m.type === 'refresh' && m.status === 'complete').length === 1); + +console.log(ok ? '\nALL GREEN\n' : '\nFAILURES\n'); +process.exit(ok ? 0 : 1); diff --git a/tools/call-test.js b/tools/call-test.js new file mode 100644 index 0000000..5bfa38c --- /dev/null +++ b/tools/call-test.js @@ -0,0 +1,188 @@ +// 1:1 calls (NIP-AC): two wallets, each in its own browser tab, a relay +// between them. The caller's 📞 / 🎥 in their conversation rings the other +// over whatever page they're on; Answer connects them — each hears and sees +// the other — mute and camera-off reach the other side, hang-up ends it on +// both. A call can be declined, and a stranger's (neither followed nor +// written to) doesn't ring, but is in the conversation as a missed call. Everything on the wire is an ephemeral gift wrap. +// Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/) +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +import { getPublicKey } from 'nostr-tools/pure'; +import { npubEncode } from 'nostr-tools/nip19'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const PORT = 5305; +const SLOW = +(process.env.SLOW || 0); +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); + +// a small relay: keeps what isn't ephemeral, sends each event to the subscriptions it matches +const stored = [], subs = new Set(), kindsSeen = [], wrapIds = new Set(); +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) + && (!f.since || e.created_at >= f.since) + && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1]))); +const server = Bun.serve({ port: PORT, + fetch: async (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 }); + // two tabs on one machine: no TURN needed (SLOW=ms makes it, and the relay + // lookups, take as long as they do out there) + if (path === '/live/turn') { if (SLOW) await sleep(SLOW); return Response.json({ iceServers: [], ttl: 600 }); } + const f = Bun.file('dist' + path); + return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); + }, + websocket: { + open(ws) { ws.data = { subs: new Map() }; }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { + const e = m[1]; kindsSeen.push(e.kind); if (e.kind === 21059) wrapIds.add(e.id); + ws.send(JSON.stringify(['OK', e.id, true, ''])); + if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e); + for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match({ ...f, since: undefined }, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e])); + } else if (m[0] === 'REQ') { + if (SLOW && m.slice(2).some((f) => (f.kinds || []).includes(10050))) { setTimeout(() => ws.send(JSON.stringify(['EOSE', m[1]])), SLOW); return; } + const s = { ws, id: m[1], filters: m.slice(2) }; + ws.data.subs.set(m[1], s); subs.add(s); + for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') { const s = ws.data.subs.get(m[1]); if (s) subs.delete(s); } + }, + close(ws) { for (const s of ws.data.subs.values()) subs.delete(s); }, + }, +}); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', + args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] }); +// our DM relays lead to the test's relay +const toLocal = (port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}; +async function wallet(name) { + const page = await (await browser.createBrowserContext()).newPage(); // a browser of its own: its own storage + await page.setViewport({ width: 390, height: 844 }); + await page.evaluateOnNewDocument(toLocal, PORT); + page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e))); + const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x); + const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive'); + return { page, mn, pk: getPublicKey(privateKeyFromSeedWords(mn)), base: cacheKeyFor(mn + '\n'), click, waitText, name }; +} +const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +const follow = (w, pks, profiles) => w.page.evaluate(([k, pks, profiles]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':profiles', JSON.stringify(profiles)); +}, [w.base, pks, profiles]); +// the call's pictures and sound actually moving: frames decoded, bytes in +const media = (page) => page.evaluate(async () => { + const v = document.querySelector('video.call-remote'), a = document.querySelector('audio.call-audio'); + const s = a && a.srcObject; + return { video: !!v && v.videoWidth > 0 && !v.paused, audio: !!s && s.getAudioTracks().some((t) => t.readyState === 'live'), local: !!document.querySelector('video.call-local')?.videoWidth }; +}); + +try { + const A = await wallet('alice'), B = await wallet('bob'), C = await wallet('carol'); + const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() }, [C.pk]: { name: 'Carol Stranger', t: Date.now() } }; + await follow(A, [B.pk], profiles); await follow(B, [A.pk], profiles); await follow(C, [B.pk], profiles); + for (const w of [A, B, C]) { await w.page.reload({ waitUntil: 'domcontentloaded' }); await w.waitText('receive'); } + const openDm = async (w, peer) => { + await w.page.goto(`http://localhost:${PORT}/` + npubEncode(peer), { waitUntil: 'domcontentloaded' }); + await w.page.waitForSelector('.prof-actions', { timeout: 20000 }); + await w.page.evaluate(() => { const b = [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)); b.click(); }); + await w.page.waitForSelector('.chat-head .call-start', { timeout: 10000 }); + }; + + console.log('[a video call]'); + await openDm(A, B.pk); + check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2)); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click()); + check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000)); + check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000)); + check('everything went as ephemeral gift wraps', kindsSeen.length > 0 && !kindsSeen.some((k) => k >= 25050 && k <= 25055) && kindsSeen.includes(21059)); + if (process.env.RING_WAIT) await sleep(+process.env.RING_WAIT); // a person reaching for the phone + const tAccept = Date.now(); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000); + const answerMs = Date.now() - tAccept; + check('answered: both connected, the clock running (' + answerMs + ' ms from Answer)', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000)); + await sleep(1500); + const ma = await media(A.page), mb = await media(B.page); + check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb })); + await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click()); + check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000)); + await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click()); + check('minimized: a bar across the top, the page usable under it', await waitFor(A.page, () => !!document.querySelector('.call-min-bar') && !document.querySelector('.call-bar'), 3000)); + await A.page.evaluate(() => document.querySelector('.call-min-bar').click()); + await waitFor(A.page, () => !!document.querySelector('.call-hang'), 3000); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + check('hanging up ends it here', await waitFor(A.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 3000)); + check('...and there', await waitFor(B.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000)); + check('...the cameras let go', await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000) && await waitFor(B.page, () => !document.querySelector('.call-overlay'), 6000)); + const logs = (page) => page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText.replace(/\s+/g, ' ').trim())); + check('the call is in the caller\'s conversation: outgoing, how long', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing video call · \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(A.page))); + await openDm(B, A.pk); + check('...and in the callee\'s: incoming', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Incoming video call · \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(B.page))); + + console.log('\n[declined]'); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + check('a voice call rings', await waitFor(B.page, () => /voice call/i.test(document.querySelector('.call-overlay')?.innerText || ''), 15000)); + await B.page.evaluate(() => document.querySelector('.call-decline').click()); + check('declined: the caller is told', await waitFor(A.page, () => /declined/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000)); + await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000); + check('...and it\'s logged so on both sides', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing voice call · Declined/.test(r.innerText)), 5000) + && await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Declined voice call/.test(r.innerText)), 5000), JSON.stringify([await logs(A.page), await logs(B.page)])); + await B.page.evaluate(() => document.querySelector('.chat-row.call-log .call-log-pill').click()); + check('a tap on a logged call calls back', await waitFor(A.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000)); + await A.page.evaluate(() => document.querySelector('.call-decline').click()); + await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000); + + console.log('\n[a remote signer\'s way: the candidates inside the offer and answer]'); + for (const w of [A, B]) await w.page.evaluate(() => localStorage.setItem('coinos-call-trickle', '0')); + const before = wrapIds.size; + await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click()); + await waitFor(B.page, () => !!document.querySelector('.call-accept'), 20000); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + check('it connects', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000)); + await sleep(1500); + const m2 = await media(A.page); + check('...picture and sound', m2.video && m2.audio, JSON.stringify(m2)); + const sent = wrapIds.size - before; + check('...with a handful of messages, not one per candidate (offer, answer, its copy for my other devices)', sent <= 3, sent + ' wraps'); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000); + for (const w of [A, B]) await w.page.evaluate(() => localStorage.removeItem('coinos-call-trickle')); + + console.log('\n[a stranger]'); + await openDm(C, B.pk); + await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000); + await sleep(5000); + check('someone Bob neither follows nor wrote to doesn\'t ring him', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); + await C.page.evaluate(() => document.querySelector('.call-hang')?.click()); + await waitFor(C.page, () => !document.querySelector('.call-overlay'), 6000); + await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000); + await sleep(4000); + check('...nor does their next call (a logged call isn\'t having written to them)', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); + await C.page.evaluate(() => document.querySelector('.call-hang')?.click()); + await openDm(B, C.pk); + check('...but it\'s in his conversation with them, as a call that didn\'t ring, and why', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText) && /people you follow or have written to/i.test(r.innerText)), 8000), + JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText)))); + check('...the caller who gave up has it as cancelled', await waitFor(C.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Cancelled voice call/.test(r.innerText)), 6000)); + for (const w of [A, B, C]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(true); +} +console.log(ok ? '\n✅ calls: rung, answered, seen and heard, ended' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js new file mode 100644 index 0000000..54c42ee --- /dev/null +++ b/tools/call-wake-test.js @@ -0,0 +1,214 @@ +// A call wakes a closed app: with Bob's coinos closed, Alice calls; the push +// the notifier would send (delivered here to Bob's service worker through +// Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open +// it with Bob's key and ring — "Alice Caller is calling" — and the tap that +// opens the app finds the offer kept for it: it rings there, Bob answers, +// they talk. A push for a stranger's call, or for something that isn't an +// offer, rings nothing. Plus the worker's check on its own (classifyCall). +// Run: bun tools/call-wake-test.js (NO_BUILD=1 to reuse dist/) +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; +import { bytesToHex } from '@noble/hashes/utils'; +import { npubEncode } from 'nostr-tools/nip19'; +import { classifyCall } from '../src/dm-inbox.js'; +import { callEvent, wrapCall, CALL } from '../src/calls.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const PORT = 5306; + +console.log('[the worker\'s check]'); +{ + const bob = generateSecretKey(), alice = generateSecretKey(), eve = generateSecretKey(); + const B = getPublicKey(bob), Al = getPublicKey(alice), E = getPublicKey(eve); + const box = [{ pubkey: B, sk: bytesToHex(bob), follows: [Al], known: [], muted: [], names: { [Al]: 'Alice' } }]; + const offer = (sk, ago = 0, kind = CALL.OFFER, to = B) => wrapCall(finalizeEvent({ ...callEvent(kind, to, 'id-1', 'v=0', kind === CALL.OFFER ? [['call-type', 'video']] : []), created_at: Math.floor(Date.now() / 1000) - ago }, sk), to); + const r = await classifyCall(offer(alice), box); + check('a fresh offer from someone followed rings, named, video', !!r && !r.quiet && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al); + check('...a stranger\'s doesn\'t ring, but is kept as quiet', (await classifyCall(offer(eve), box))?.quiet === true); + check('...nor someone muted', (await classifyCall(offer(alice), [{ ...box[0], muted: [Al] }])) === null); + check('...but someone written to rings', (await classifyCall(offer(eve), [{ ...box[0], rings: [E] }]))?.quiet === false); + check('...not someone who only wrote to us', (await classifyCall(offer(eve), [{ ...box[0], known: [E], rings: [] }]))?.quiet === true); + check('...an offer over a minute old doesn\'t', (await classifyCall(offer(alice, 90), box)) === null); + check('...nor a candidate or a hang-up', (await classifyCall(offer(alice, 0, CALL.ICE), box)) === null && (await classifyCall(offer(alice, 0, CALL.HANGUP), box)) === null); + check('...nor a wrap for someone else', (await classifyCall(offer(alice, 0, CALL.OFFER, E), box)) === null); +} + +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); +const stored = [], subs = new Set(), callWraps = new Map(); +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) + && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1]))); +const server = Bun.serve({ port: PORT, + fetch: async (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 }); + if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 }); + const w = /^\/nwcpush\/wrap\/([0-9a-f]{64})$/.exec(path); // the notifier's keeping of call wraps + if (w) return callWraps.has(w[1]) ? Response.json(callWraps.get(w[1])) : Response.json({ error: 'gone' }, { status: 404 }); + const f = Bun.file('dist' + path); + return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); + }, + websocket: { + open(ws) { ws.data = new Map(); }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { + const e = m[1]; + ws.send(JSON.stringify(['OK', e.id, true, ''])); + if (e.kind === 21059) callWraps.set(e.id, e); else if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e); + for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e])); + } else if (m[0] === 'REQ') { + const s = { ws, id: m[1], filters: m.slice(2) }; + ws.data.set(m[1], s); subs.add(s); + for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') subs.delete(ws.data.get(m[1])); + }, + close(ws) { for (const s of ws.data.values()) subs.delete(s); }, + }, +}); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', + args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] }); +const toLocal = (port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}; +async function wallet(name) { + const context = await browser.createBrowserContext(); + await context.overridePermissions(`http://localhost:${PORT}`, ['notifications']); + const page = await context.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.evaluateOnNewDocument(toLocal, PORT); + page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e))); + const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x); + const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive'); + const sk = privateKeyFromSeedWords(mn); + return { context, page, mn, sk, pk: getPublicKey(sk), base: cacheKeyFor(mn + '\n'), click, waitText, name }; +} +const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +// the push the notifier sends, delivered to a context's service worker +async function push(w, data) { + const cdp = await w.page.target().createCDPSession(); + const regs = []; + cdp.on('ServiceWorker.workerRegistrationUpdated', (e) => regs.push(...e.registrations)); + await cdp.send('ServiceWorker.enable'); + for (let i = 0; i < 25 && !regs.some((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted); i++) await sleep(200); + const reg = regs.find((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted); + await cdp.send('ServiceWorker.deliverPushMessage', { origin: `http://localhost:${PORT}`, registrationId: reg.registrationId, data: JSON.stringify(data) }); + await cdp.detach(); +} +const notifications = (page) => page.evaluate(async () => (await (await navigator.serviceWorker.ready).getNotifications()).map((n) => n.title + ' | ' + n.body + ' | ' + n.tag)); + +try { + console.log('\n[a closed app, rung]'); + const A = await wallet('alice'), B = await wallet('bob'); + const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() } }; + // their follow lists, where the app (and Bob's worker mirror) read them + for (const [w, other] of [[A, B], [B, A]]) { + // (newer than the starter list a new wallet publishes for itself) + stored.push(finalizeEvent({ kind: 3, created_at: Math.floor(Date.now() / 1000) + 120, content: '', tags: [['p', other.pk]] }, w.sk)); + await w.page.evaluate(([k, pk, profiles]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':profiles', JSON.stringify(profiles)); + }, [w.base, other.pk, profiles]); + await w.page.reload({ waitUntil: 'domcontentloaded' }); + await w.waitText('receive'); + } + check('Bob\'s app has given his worker what it needs: his key, his follows', await waitFor(B.page, (pk) => new Promise((res) => { + const r = indexedDB.open('coinos-dm-inbox'); + r.onsuccess = () => { try { const g = r.result.transaction('inbox').objectStore('inbox').getAll(); g.onsuccess = () => res(g.result.some((x) => x.sk && (x.follows || []).includes(pk))); g.onerror = () => res(false); } catch { res(false); } }; + r.onerror = () => res(false); + }), 20000, A.pk)); + await B.page.evaluate(() => navigator.serviceWorker.ready); + // Bob closes coinos + await B.page.goto('about:blank'); + // Alice calls + await A.page.goto(`http://localhost:${PORT}/` + npubEncode(B.pk), { waitUntil: 'domcontentloaded' }); + await A.page.waitForSelector('.prof-actions', { timeout: 20000 }); + await A.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click()); + await A.page.waitForSelector('.chat-head .call-start', { timeout: 10000 }); + const wrapsBefore = new Set(callWraps.keys()); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + let offerWrap = null; + for (let i = 0; i < 50 && !offerWrap; i++) { await sleep(200); offerWrap = [...callWraps.values()].find((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); } + check('Alice\'s offer goes out, big enough for the notifier to wake Bob for', !!offerWrap, offerWrap && offerWrap.content.length + ' chars'); + const small = [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.content.length < 2000); + check('...and what follows it is small enough not to', small.length >= 1 && small.every((w) => w.content.length < 1500), small.map((w) => w.content.length).join(',')); + await push(B, { type: 'notify', reason: 'call', wrapId: offerWrap.id }); + await sleep(1500); + // the tap: the worker opens the app at ?open=call + await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' }); + const ns = await notifications(B.page); + check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns)); + check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000)); + // Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one) + const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length; + const ringingCands = toBob(); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + check('Bob answers: they\'re connected', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000) + && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000)); + check('Alice sends her candidates again once answered (Bob\'s app heard none of the first ones)', toBob() >= 2 * ringingCands && ringingCands > 0, ringingCands + ' → ' + toBob()); + check('...and hear each other', await waitFor(B.page, () => { const a = document.querySelector('audio.call-audio'); return !!a && !!a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live'); }, 5000)); + check('the "is calling" notification is gone once answered', await waitFor(B.page, async () => !(await (await navigator.serviceWorker.ready).getNotifications({ tag: 'call' })).length, 5000)); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000); + + console.log('\n[pushes that ring nothing]'); + await B.page.goto('about:blank'); + const eve = generateSecretKey(); + const strangers = wrapCall(finalizeEvent(callEvent(CALL.OFFER, B.pk, 'x-1', 'v=0\r\n' + 'a'.repeat(2400), [['call-type', 'voice']]), eve), B.pk); + callWraps.set(strangers.id, strangers); + await push(B, { type: 'notify', reason: 'call', wrapId: strangers.id }); + await push(B, { type: 'notify', reason: 'call', wrapId: 'f'.repeat(64) }); // one the notifier no longer has + await sleep(1500); + await B.page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await B.waitText('receive'); + check('a stranger\'s offer, or one gone, rings nothing', !(await notifications(B.page)).some((n) => /calling/.test(n)) && !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); + await B.page.goto(`http://localhost:${PORT}/` + npubEncode(getPublicKey(eve)), { waitUntil: 'domcontentloaded' }); + await B.page.waitForSelector('.prof-actions', { timeout: 20000 }); + await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click()); + check('...but the stranger\'s call is in the app once it opens: missed, didn\'t ring', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /didn.t ring/i.test(r.innerText)), 10000)); + + console.log('\n[rung while closed, and nobody came]'); + await B.page.goto('about:blank'); + const before2 = new Set(callWraps.keys()); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + let offer2 = null; + for (let i = 0; i < 50 && !offer2; i++) { await sleep(200); offer2 = [...callWraps.values()].find((w) => !before2.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); } + await push(B, { type: 'notify', reason: 'call', wrapId: offer2.id }); + await sleep(1500); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); // Alice gives up + // Bob opens coinos later on, past the call's minute (the kept offer dated back) + const page2 = await B.context.newPage(); + await page2.goto(`http://localhost:${PORT}/icon-192.png`, { waitUntil: 'domcontentloaded' }); // the origin, without the app (which would take the offer) + await page2.evaluate(async () => { + const c = await caches.open('coinos-call'), r = await c.match('/__pending-call'); + const j = await r.json(); j.at -= 120_000; + await c.put('/__pending-call', new Response(JSON.stringify(j), { headers: { 'content-type': 'application/json' } })); + }); + await page2.close(); + await B.page.goto(`http://localhost:${PORT}/` + npubEncode(A.pk), { waitUntil: 'domcontentloaded' }); + check('the app doesn\'t ring for a call long over', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay')))); + await B.page.waitForSelector('.prof-actions', { timeout: 20000 }); + await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click()); + check('...it\'s a missed call in the conversation', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText)), 10000), + JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText)))); + for (const w of [A, B]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(true); +} +console.log(ok ? '\n✅ a call wakes a closed app' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js index 512b8bd..94b343a 100644 --- a/tools/composer-media-test.js +++ b/tools/composer-media-test.js @@ -18,7 +18,7 @@ const page = await browser.newPage(); const uploads = []; await page.setRequestInterception(true); // every public media host the upload may try first (nothing leaves the machine) -const HOSTS = /blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/; +const HOSTS = /media\.example|blossom\.ditto\.pub|blossom\.primal\.net|nostr\.download|nostr\.build/; page.on('request', (req) => { const u = req.url(); if (HOSTS.test(u) && req.method() === 'PUT') { @@ -46,6 +46,23 @@ try { const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); await click('skip verification'); await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('button[aria-label="Settings"]').click()); + await sleep(300); + await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find(e => e.textContent.includes('Nostr')).click()); + await page.waitForSelector('[data-key="media-servers"] textarea'); + const setServers = async (value) => page.evaluate(value => { + const card = document.querySelector('[data-key="media-servers"]'); + const input = card.querySelector('textarea'); input.value = value; + input.dispatchEvent(new Event('input', {bubbles:true})); + card.querySelector('button').click(); + }, value); + await setServers('https://media.example/'); + check('settings save a normalized custom server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]')); + await setServers('https://media.example/path'); + check('invalid settings preserve the previous server', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === '["https://media.example"]')); + await page.evaluate(() => document.querySelectorAll('[data-key="media-servers"] button')[1].click()); + check('Use default clears the override', await page.evaluate(() => localStorage.getItem('btc-wallet-media-servers') === null)); + await setServers('https://media.example'); const base = cacheKeyFor(mn + '\n'); const A = 'a'.repeat(63) + '9'; await page.evaluate(([k, a]) => { @@ -91,7 +108,7 @@ try { document.querySelector('.thread-reply-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); }); const landed = await waitFor(() => /nostr\.download\/f{64}\.gif/.test(document.querySelector('.thread-reply-input')?.value || ''), 15000); - check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1, + check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1 && uploads[0] === 'https://media.example/upload', JSON.stringify(await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)) + ' uploads=' + uploads.length); check('...and the preview shows it', await waitFor(() => !!document.querySelector('.draft-preview img, .draft-preview .note-img, .draft-preview a'), 5000)); // the upload re-rendered the page: the field must still be editable diff --git a/tools/deepl-translate.test.js b/tools/deepl-translate.test.js deleted file mode 100644 index 39975d0..0000000 --- a/tools/deepl-translate.test.js +++ /dev/null @@ -1,38 +0,0 @@ -// names/translate.js translateDeepl: the reader's key goes to the right -// DeepL host as a DeepL-Auth-Key header, only the prose is sent (links and -// tags kept), the detected language comes back, and DeepL's refusals turn -// into statuses the client can word (401 bad key, 402 quota). -// Run: bun test tools/deepl-translate.test.js -import { test, expect } from 'bun:test'; -import { translateDeepl } from '../names/translate.js'; - -const fake = (status = 200, seen = []) => async (url, init) => { - const body = JSON.parse(init.body); - seen.push({ url, auth: init.headers.authorization, body }); - if (status !== 200) return new Response('{}', { status }); - return new Response(JSON.stringify({ translations: body.text.map((t) => ({ text: '[it] ' + t, detected_source_language: 'EN' })) }), { status: 200 }); -}; - -test('a free key goes to api-free, as DeepL-Auth-Key, prose only', async () => { - const seen = []; - const out = await translateDeepl('Good morning everyone, look at this https://example.com/a.png #nostr', 'it', 'abcdef01-2345-6789-abcd-ef0123456789:fx', fake(200, seen)); - expect(seen[0].url).toBe('https://api-free.deepl.com/v2/translate'); - expect(seen[0].auth).toBe('DeepL-Auth-Key abcdef01-2345-6789-abcd-ef0123456789:fx'); - expect(seen[0].body.target_lang).toBe('IT'); - expect(seen[0].body.text.join(' ')).not.toContain('https://'); - expect(out.source).toBe('en'); - expect(out.text).toContain('[it] '); - expect(out.text).toContain('https://example.com/a.png'); - expect(out.text).toContain('#nostr'); -}); -test('a paid key goes to api.deepl.com; English means EN-US', async () => { - const seen = []; - await translateDeepl('Buongiorno a tutti, come state oggi?', 'en', 'abcdef01-2345-6789-abcd-ef0123456780', fake(200, seen)); - expect(seen[0].url).toBe('https://api.deepl.com/v2/translate'); - expect(seen[0].body.target_lang).toBe('EN-US'); -}); -test('refusals: bad key 401, quota 402', async () => { - await expect(translateDeepl('Hello there my friends, how are you', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(403))).rejects.toMatchObject({ status: 401 }); - await expect(translateDeepl('Hello there my friends, how are you all', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(456))).rejects.toMatchObject({ status: 402 }); - await expect(translateDeepl('Hello', 'it', 'not a key')).rejects.toMatchObject({ status: 401 }); -}); diff --git a/tools/feed-appetite-test.js b/tools/feed-appetite-test.js new file mode 100644 index 0000000..b79a2a9 --- /dev/null +++ b/tools/feed-appetite-test.js @@ -0,0 +1,159 @@ +// The feed pages to the reader's appetite. Reading at an ordinary pace it +// shows ten more posts at a time, and asks the relays for older ones while +// a page or two is still in hand — never once the reader has run out. A +// reader flying through (five screens in ten seconds, by their own hand) +// gets bigger pages, from further above the foot of the feed; standing +// still a while brings the ordinary page back. A page held still under an +// insert above is not travel. +// Run: bun tools/feed-appetite-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 SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (i) => finalizeEvent({ kind: 1, created_at: now - 60 * i, tags: [], content: 'post number ' + i + ' of the long timeline' }, SK); +const cached = Array.from({ length: 60 }, (_, i) => post(i)); // in the feed cache +const older = Array.from({ length: 300 }, (_, i) => post(60 + i)); // on the relays, for the asks +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'] }); +const page = await browser.newPage(); +// The relays, answered in the page: an ask for older posts (a filter with +// `until` for the author) gets the thirty newest at or before it, after a +// moment, then EOSE. Every ask is logged with the feed's state at that moment. +await page.evaluateOnNewDocument((fx) => { + window.__asks = []; window.__steps = []; + window.__snap = () => { const f = document.querySelector('.notes-feed'); return { t: Date.now(), shown: +(f?.dataset.shown || 0), held: +(f?.dataset.held || 0), remaining: Math.round(document.documentElement.scrollHeight - window.scrollY - window.innerHeight) }; }; + const Real = window.WebSocket; + const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); }; + const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); }; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const f = m.slice(2).find((f) => f.until && (f.kinds || []).includes(1) && (f.authors || []).includes(fx.author)); + if (f) { + if (!window.__asks.some((a) => a.until === f.until)) window.__asks.push({ ...window.__snap(), until: f.until, limit: f.limit || 0 }); + const evs = fx.older.filter((e) => e.created_at <= f.until).slice(0, f.limit || 30); + setTimeout(() => { for (const ev of evs) send1(ws, m[1], ev); eose(ws, m[1]); }, 300); + return; + } + } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); +}, { author: AUTHOR, older }); +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, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; }; +const snap = () => page.evaluate(() => window.__snap()); +const steps = () => page.evaluate(() => window.__steps); +const asks = () => page.evaluate(() => window.__asks); +// a scroll by the reader's own hand: an input stamp, then the page moves +const handScroll = (dy) => page.evaluate((dy) => { window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); window.scrollBy({ top: dy, behavior: 'instant' }); }, dy); +const H = 500; + +try { + await page.setViewport({ width: 390, height: H, hasTouch: true }); + 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: 'Long Timeline', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, cached]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the cached posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 5, null, 20000)); + // every change of the page count, with the feed's state at that moment + await page.evaluate(() => { + const f = document.querySelector('.notes-feed'); + let last = +f.dataset.shown; + new MutationObserver(() => { const n = +(document.querySelector('.notes-feed')?.dataset.shown || 0); if (n !== last) { window.__steps.push({ ...window.__snap(), from: last }); last = n; } }) + .observe(document.querySelector('#app') || document.body, { attributes: true, subtree: true, attributeFilter: ['data-shown'] }); + }); + const s0 = await snap(); + check('it opens on a page of ten, with the cached posts in hand', s0.shown === 10 && s0.held >= 30, JSON.stringify(s0)); // (the feed cache keeps thirty) + + console.log('\n[reading at an ordinary pace]'); + // a few posts every so often, no hand on it (the browser's own scroll: the + // trail counts only travel within a moment of an input) + for (let i = 0; i < 60; i++) { + await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' })); + await sleep(600); + const s = await snap(); + if (s.shown >= 60) break; + } + await waitFor(() => +(document.querySelector('.notes-feed')?.dataset.shown || 0) >= 60, null, 8000); + const slow = await steps(); + check('pages of ten', slow.length >= 3 && slow.every((s) => s.shown - s.from <= 10), slow.map((s) => s.from + '→' + s.shown).join(' ')); + const a = await asks(); + check('the relays were asked for older posts while posts were still in hand', a.length >= 1 && a[0].shown < a[0].held, JSON.stringify(a[0])); + const at60 = slow.find((s) => s.shown >= 60); + check('…so reaching the end of what was held found more already there', !!at60 && at60.held > at60.shown, JSON.stringify(at60)); + check('the ordinary pace never asked for more than a page at a time', slow.every((s) => s.shown - s.from <= 10)); + + console.log('\n[flying through]'); + await sleep(10_500); // the trail forgets the slow spell + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await sleep(600); + const before = await steps(); + const sB = await snap(); + // eleven screens in half a second, by hand; a breath; eleven more + for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); } + await sleep(1500); + for (let i = 0; i < 11; i++) { await handScroll(H); await sleep(45); } + await waitFor((n) => window.__steps.length > n && window.__steps.slice(n).some((s) => s.shown - s.from >= 30), before.length, 6000); + await sleep(1500); + const fast = (await steps()).slice(before.length); + const big = fast.find((s) => s.shown - s.from >= 20); + check('a bigger page at once', !!big, fast.map((s) => s.from + '→' + s.shown).join(' ') || 'no page'); + // (a page begun mid-flight is sized to the screens flown so far; the ones after are the full three) + check('…growing to three pages\' worth', fast.some((s) => s.shown - s.from >= 30), fast.map((s) => s.shown - s.from).join(' ')); + check('…asked for from further above the foot than the ordinary lead', !!big && big.remaining > Math.max(3000, 4 * H), big ? big.remaining + ' px left below' : ''); + const a2 = (await asks()).slice(a.length); + check('…and the relays asked for more per round trip', a2.some((x) => x.limit >= 60), a2.map((x) => x.limit).join(' ') || 'no ask'); + + console.log('\n[standing still again]'); + await sleep(10_500); + const before3 = await steps(); + for (let i = 0; i < 30; i++) { + await page.evaluate(() => window.scrollBy({ top: 300, behavior: 'instant' })); + await sleep(600); + if ((await steps()).length > before3.length) break; + } + await sleep(500); + const calm = (await steps()).slice(before3.length); + check('the ordinary page is back', calm.length >= 1 && calm.every((s) => s.shown - s.from <= 10), calm.map((s) => s.from + '→' + s.shown).join(' ') || 'no page'); + 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✅ the feed pages to the reader\'s appetite' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/feed-conversations-test.js b/tools/feed-conversations-test.js new file mode 100644 index 0000000..e762d1a --- /dev/null +++ b/tools/feed-conversations-test.js @@ -0,0 +1,133 @@ +// Conversations: a view of any feed (the chip beside Notes, Images, Videos +// and Articles) — the replies its people write, each under the post it +// answers (Amethyst's Conversations). Notes keeps only their posts; a +// profile's Replies tab shows the same context; the view persists across +// feeds and reloads like the other views. +// Run: bun tools/feed-conversations-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 SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk = SKA, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk); +const B1 = post('bob root here, a question for everyone', 400, SKB); +const A1 = post('alice root post about nothing', 300); +const MISSING = 'f'.repeat(64); +const R1 = post('alice answers bob right here', 200, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]); +const R2 = post('alice replies to a post nobody has', 100, SKA, [['e', MISSING, '', 'root']]); +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'] }); +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 rowIds = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post'))); +const contextOf = (id) => page.evaluate((id) => { + const r = document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'); + const c = r && r.querySelector('.note-context'); + return c ? { of: c.getAttribute('data-context-of'), text: c.innerText, card: !!c.querySelector('.quote-card.clickable') } : null; +}, id); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + 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, 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 Replier', about: 'alice bio here', t: Date.now() }, [bob]: { name: 'Bob Asker', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, ALICE, BOB, [A1, B1], [R1, R2]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('Following paints their posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000)); + const following = await rowIds(); + check('…only posts: no replies in Following', following.includes(A1.id) && following.includes(B1.id) && !following.includes(R1.id) && !following.includes(R2.id), following.map((x) => x.slice(0, 6)).join(',')); + const modes = () => page.$$eval('.media-mode', (l) => l.map((e) => e.getAttribute('data-mode') + '=' + e.textContent.trim() + (e.classList.contains('on') ? '*' : '')).join(' ')); + check('Notes and Conversations lead the view chips', await modes() === 'all=Notes* replies=Conversations images=Images videos=Videos articles=Articles', await modes()); + check('no Conversations feed chip of its own', !(await page.$$eval('.feed-chips > .feed-chip', (l) => l.some((e) => /Conversations/.test(e.textContent))))); + + console.log('\n[Conversations]'); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click()); + check('the replies paint', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 2, null, 20000)); + const convo = await rowIds(); + check('…only replies, newest first', JSON.stringify(convo) === JSON.stringify([R2.id, R1.id]), convo.map((x) => x.slice(0, 6)).join(',')); + check('the view chip is on, the feed chip still Following', /replies=Conversations\*/.test(await modes()) && await page.$eval('.feed-chip[data-key="chip:following"]', (e) => e.classList.contains('on'))); + const c1 = await contextOf(R1.id); + check('a reply sits under the post it answers (in hand from Following, no relay asked)', !!c1 && c1.of === B1.id && c1.card && /Replying to/.test(c1.text) && /Bob Asker/.test(c1.text) && /bob root here/.test(c1.text), JSON.stringify(c1)); + check('…and the header no longer repeats "reply"', !(await page.$eval('.notes-feed .note-post[data-zap-post="' + R1.id + '"] .note-header', (e) => /\breply\b/i.test(e.innerText)))); + check('a parent nobody has: the card says so', await waitFor((id) => { const c = document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context'); return !!c && /find that note/i.test(c.innerText); }, R2.id, 8000), (await contextOf(R2.id) || {}).text); + // the reply's own tap (its text, not the card above it): its thread, at the reply + await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), R1.id); + check('tapping the reply opens the thread at the reply', await waitFor((id) => !!document.querySelector('[data-focus-note="1"][data-zap-post="' + id + '"]'), R1.id, 8000)); + check('…with Bob\'s post as its root', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('.feed-chips'), B1.id, 4000)); + await page.goBack(); await sleep(800); + check('Back lands on the Conversations view again', await waitFor(() => document.querySelector('.media-mode[data-mode="replies"]')?.classList.contains('on') && !!document.querySelector('.note-post[data-zap-post]'), null, 6000)); + // the context card's tap: the parent's own thread + await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-context .quote-card').click(), R1.id); + check('tapping the post it answers opens that post, at the top', await waitFor((id) => document.querySelector('.thread-card [data-zap-post]')?.getAttribute('data-zap-post') === id && !document.querySelector('[data-focus-note]') && !document.querySelector('.feed-chips'), B1.id, 8000)); + await page.goBack(); await sleep(800); + await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000); + + console.log('\n[a profile\'s Replies tab]'); + await page.evaluate((id) => document.querySelector('.note-post[data-zap-post="' + id + '"] .note-avatar').click(), R1.id); + check('Alice\'s profile opens', await waitText('alice bio here', 8000)); + await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="replies"]').click()); + check('her Replies tab lists the reply', await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), R1.id, 8000)); + const pc = await contextOf(R1.id); + check('…under the post it answers', !!pc && pc.of === B1.id && /bob root here/.test(pc.text), JSON.stringify(pc)); + await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="posts"]').click()); + await waitFor((id) => !!document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]'), A1.id, 8000); + check('her Posts tab shows no context block', (await page.$$('.notes-feed .note-context')).length === 0); + await page.goBack(); await sleep(800); + await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000); + + console.log('\n[the view on another feed]'); + await page.evaluate(() => document.querySelector('.feed-chip.add').click()); + check('the editor has no replies toggle (the view is the chip)', await waitFor(() => !!document.querySelector('.feed-interests-toggle'), null, 4000) && !(await page.$('.feed-replies-toggle'))); + await page.evaluate(() => { + const name = document.querySelector('.chat-page input[type=text]'); name.value = 'Alice talks'; name.dispatchEvent(new Event('input', { bubbles: true })); + const follows = [...document.querySelectorAll('label')].find((l) => /Everyone I follow/.test(l.textContent)).querySelector('input'); follows.click(); + }); + await click('save'); await sleep(600); + check('saved as a chip', await waitFor(() => [...document.querySelectorAll('.feed-chip')].some((e) => e.textContent.trim() === 'Alice talks' && e.classList.contains('on')), null, 6000)); + check('…opened in the Conversations view, the replies borrowed from the feeds in hand', /replies=Conversations\*/.test(await modes()) && await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [R1.id, R2.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(',')); + check('…each with its context', (await contextOf(R1.id) || {}).of === B1.id); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click()); + check('Notes: the same feed\'s posts', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got.includes(ids[0]) && got.includes(ids[1]); }, [A1.id, B1.id], 8000), (await rowIds()).map((x) => x.slice(0, 6)).join(',')); + check('…with no context blocks', (await page.$$('.notes-feed .note-context')).length === 0); + await page.evaluate(() => document.querySelector('.feed-chip[data-key="chip:following"]').click()); + check('Following in Notes is posts only', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.includes(ids[0]) && !got.includes(ids[1]); }, [A1.id, R1.id], 8000)); + await page.evaluate(() => document.querySelector('.media-mode[data-mode="replies"]').click()); + check('…and its Conversations view is back at once', await waitFor((ids) => { const got = [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return got.length === 2 && got[0] === ids[0]; }, [R2.id], 8000)); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('a reload opens on the view last chosen, from its own cache', await waitFor((ids) => document.querySelector('.media-mode[data-mode="replies"]')?.classList.contains('on') && [...document.querySelectorAll('.notes-feed .note-post[data-zap-post]')].map((r) => r.getAttribute('data-zap-post')).includes(ids[0]), [R1.id], 20000)); + 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✅ conversations view: the replies they write, under what they answer' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js index 2c84b14..58e942b 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -126,10 +126,16 @@ try { await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 }); console.log('\n[the switch]'); - check('All / Images / Videos / Articles sit under the chips, All on', await page.evaluate(() => - [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'All*,Images,Videos,Articles')); + check('Notes / Conversations / Images / Videos / Articles sit under the chips, Notes on', await page.evaluate(() => + [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Articles')); + // five chips are wider than a phone: the strip scrolls sideways, the page does not get wider + check('…the strip scrolls sideways rather than widening the page', await page.evaluate(() => { + const s = document.querySelector('.media-modes'); + return s.scrollWidth > s.clientWidth && getComputedStyle(s).overflowX === 'auto' && document.documentElement.scrollWidth <= innerWidth; + }), 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))); @@ -156,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/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js index 632bbc6..80e2fde 100644 --- a/tools/feed-wheel-reverse-test.js +++ b/tools/feed-wheel-reverse-test.js @@ -5,15 +5,18 @@ const source = await Bun.file('src/app.js').text(); const start = source.indexOf('(() => {', source.indexOf('// The feed scrolls like a phone')); const end = source.indexOf("\nwindow.addEventListener('load'", start); assert(start > 0 && end > start); -function fixture() { +function fixture(inside = '', { feedOnPage = true } = {}) { let now = 100, id = 0; const handlers = new Map(), frames = new Map(), steps = []; const window = { scrollY: 5000, addEventListener: (name, fn) => handlers.set(name, fn), scrollBy: ({ top }) => { steps.push(top); window.scrollY += top; }, }; - const document = { body: {}, addEventListener() {}, removeEventListener() {} }; - const target = { closest: (selector) => selector.includes('.feed-page') ? target : null, + // the page shows a feed (or, for a thread case, a thread) unless told otherwise + const document = { body: {}, addEventListener() {}, removeEventListener() {}, + querySelector: (selector) => (feedOnPage && selector.split(/,\s*/).some((p) => p === (inside === '.thread-page' ? '.thread-page' : '.feed-page')) ? {} : null) }; + // the target sits inside the given element (over the posts unless it names something else) + const target = { closest: (selector) => selector.split(/,\s*/).some((p) => p === inside) ? target : null, scrollHeight: 100, clientHeight: 100, parentElement: document.body }; runInNewContext(source.slice(start, end), { window, document, matchMedia: () => ({ matches: false }), performance: { now: () => now }, @@ -33,42 +36,61 @@ for (const dir of [1, -1]) { const f = fixture(); f.wheel(dir); f.advance(17); f.frame(); f.wheel(dir); f.advance(17); f.frame(); - // A busy main thread has missed several frames when the reverse arrives. + // A busy main thread has missed several frames when the turn back arrives. f.advance(120); const before = f.y; + // [flying: the first turn back is a brake] f.wheel(-dir); - assert((f.y - before) * dir < 0, 'first reverse notch responds immediately'); - assert(Math.abs(f.y - before) < 20, 'reverse starts with a small bounded step'); - const reversed = f.y; + assert.equal(f.y, before, 'the brake notch moves nothing by itself'); f.frame(0); // a queued animation frame can carry an older timestamp - assert.equal(f.y, reversed, 'old frame time cannot revive the previous direction'); - f.advance(17); f.frame(); - assert((f.y - reversed) * dir < 0, 'next frame continues in the new direction'); - assert(Math.abs(f.y - reversed) < 20, 'new velocity is not applied to the old frame delay'); + assert.equal(f.y, before, 'old frame time moves nothing'); + settle(f, 30); + const over = (f.y - before) * dir; + assert(over >= 0 && over < 120, 'the brake eases the flight out within a short way, never the other way: ' + over); + // [the next notch back: the gentle start] + const stopped = f.y; + f.wheel(-dir); + assert((stopped - f.y) * dir > 0 && (stopped - f.y) * dir < 20, 'the gentle start responds at once with a small step'); settle(f); - // The turn back is a plain notch from rest: its full travel, no flight the other way. - const back = (before - f.y) * dir; - assert(back > 280 && back < 330, 'a reverse notch against a coast travels one notch: ' + back); - // For a moment after a reversal notches don't wind the speed up: a - // reversal and a quick second notch travel about two notches, not a flight. - let calm; - { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); // coasting again, the other way - const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); calm = (f.y - y) * dir; - assert(calm > 540 && calm < 700, 'a reversal and a quick second notch: about two notches of travel: ' + calm); } - // From rest, well after, the wind-up is back: the same two quick notches go further. - { f.advance(2500); const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > calm + 40, 'two quick notches with the wind-up go further: ' + s + ' vs ' + calm); } - // A spin that keeps going after a reversal winds up again from its third - // notch: a reversal plus four quick notches goes well past five plain ones. - let spin; - { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); - const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); spin = (f.y - y) * dir; - assert(spin > 5 * 300 + 150, 'a spin on after a reversal winds up again: ' + spin); } - // ...and the same five quick notches from rest go about as far (the two calm ones cost little). - { f.advance(2500); const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); const s = (f.y - y) * dir; - assert(s < spin * 1.25, 'five from rest vs five after a reversal: ' + s + ' vs ' + spin); settle(f, 200); } + const back = (stopped - f.y) * dir; + assert(back > 130 && back < 170, 'the notch after the brake creeps back half a notch: ' + back); + // [then the wind-up] + { const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f); + const on = (y - f.y) * dir; assert(on > 600, 'the notches after wind up: ' + on); } + // The gentle start waits a while for its notch: a second and a half after the brake, still gentle. + { f.advance(2500); f.wheel(dir); f.advance(17); f.frame(); f.wheel(dir); f.advance(120); f.frame(); + f.wheel(-dir); settle(f, 30); f.advance(1500); const y = f.y; f.wheel(-dir); settle(f); + const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'a notch back a while after the brake is still the gentle start: ' + s); } + // ...but long after, the brake forgotten, a notch back is a plain push from rest. + { f.advance(2500); f.wheel(dir); f.advance(17); f.frame(); f.wheel(dir); f.advance(120); f.frame(); + f.wheel(-dir); settle(f, 30); f.advance(3000); const y = f.y; f.wheel(-dir); settle(f); + const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'long after the brake, a notch back is a full push: ' + s); } + // A stroll (one notch) turned back has nothing worth braking: the gentle start at once. + { f.advance(2500); f.wheel(dir); f.advance(100); f.frame(); const y = f.y; f.wheel(-dir); + assert((y - f.y) * dir > 0 && (y - f.y) * dir < 20, 'against a stroll the turn back starts at once, with a small step'); + settle(f); const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'against a stroll the turn back is the gentle start: ' + s); } // From rest a notch either way is a full push. { f.advance(2500); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'from rest a notch either way is a full push: ' + s); } - // A turn back against a coast that only just died is a reversal too (caught, no wind-up). - { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 520 && s < 700, 'reversing right after the coast dies: no wind-up: ' + s); } + // A turn back against a coast that only just died is the gentle start too. + { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 130 && s < 170, 'reversing right after the coast dies is the gentle start: ' + s); } + // Well after the coast died it is a plain notch from rest, whichever way. + { f.advance(2500); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a notch the other way from rest is a full push: ' + s); } +} +// A notch over a video (or an audio player) in the feed coasts like any +// other — the wheel does nothing to a player itself — and so does one on a +// thread page, and one with the pointer anywhere else on the page while a +// feed is up (the rail, the wallet column, the header). +for (const sel of ['video', 'audio', '.thread-page', '.wallet-column', '']) { + const f = fixture(sel); + const y = f.y; f.wheel(1); settle(f); + assert(f.y - y > 280 && f.y - y < 330, 'a notch over ' + sel + ' coasts a full push: ' + (f.y - y)); +} +// ...while a notch in a text box, or in the media pager, is left alone — and +// so is every notch on a page with no feed on it (the wallet). +for (const sel of ['textarea', '.media-pager']) { + const f = fixture(sel); + const y = f.y; f.wheel(1); settle(f); + assert.equal(f.y, y, 'a notch in ' + sel + ' is not ours'); } -console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a plain notch, two calm notches, then the wind-up is back'); +{ const f = fixture('', { feedOnPage: false }); const y = f.y; f.wheel(1); settle(f); assert.equal(f.y, y, 'no feed on the page: the wheel is the browser\'s'); } +console.log('✓ a turn back against a flight brakes, the next notch is the gentle start, then the wind-up; against a stroll or a dead coast the gentle start at once; stale frames; videos and threads coast'); diff --git a/tools/go-live-test.js b/tools/go-live-test.js new file mode 100644 index 0000000..82d6c7c --- /dev/null +++ b/tools/go-live-test.js @@ -0,0 +1,150 @@ +// Going live: a wallet with a coinos name opens Go live from the composer, +// sees its camera, starts — the recording goes over the WebSocket to the live +// server (coinosv3-server live/, run here beside the built app), the stream is +// announced as its kind 30311 pointing at the HLS — and someone else opening +// the stream's link watches it. Ending says so in a last 30311 carrying the +// recording, and the recording is complete. +// Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/) +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { naddrEncode } from 'nostr-tools/nip19'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const PORT = 5304; +const dir = mkdtempSync(tmpdir() + '/go-live-'); + +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); +// the registrar, as the live server asks it: the name is whoever is sending's +let owner = null; +const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner } : { taken: false }) }); +process.env.LIVE_DATA = dir; +process.env.NAMES_URL = names.url.origin; +process.env.LIVE_BASE = `http://localhost:${PORT}/live`; +const live = await import(process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js'); +const server = Bun.serve({ port: PORT, websocket: live.websocket, fetch: async (req, srv) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/live/')) { + if (path === '/live/ingest') try { owner = JSON.parse(atob(new URL(req.url).searchParams.get('auth'))).pubkey; } catch {} + return live.handle(req, srv); + } + const f = Bun.file('dist' + path); + return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', + args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] }); + +const relayStub = (fx) => { + window.__published = []; + const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) + && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1]))); + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + if (/\/live\/ingest/.test(String(a[0]))) return ws; // the live server itself + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ' && m[2]) { + const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e))); + if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0); + } + if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]); + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}; +const helpers = (page) => ({ + click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x), + waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }, + waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }, +}); +const lives = (page) => page.evaluate(() => window.__published.filter((e) => e.kind === 30311)); + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.evaluateOnNewDocument(relayStub, []); + // the registrar on the open internet is not asked: the name below is what the wallet holds + await page.setRequestInterception(true); + page.on('request', (r) => (/names\.coinos\.io|nwcpush/.test(r.url()) ? r.respond({ status: 404, body: '{}' }) : r.continue())); + page.on('dialog', (d) => d.accept()); + const errs = []; page.on('pageerror', (e) => errs.push(String(e))); + const { click, waitText, waitFor } = helpers(page); + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive'); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate((k) => { + localStorage.setItem(k + ':names', JSON.stringify({ byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, base); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive'); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + + console.log('[getting ready]'); + await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000); + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + check('the composer has a Go live button', await waitFor(() => !!document.querySelector('.go-live-tool'), 5000)); + await page.evaluate(() => document.querySelector('.go-live-tool').click()); + check('Go live shows the camera', await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000)); + check('...mirrored, as a front camera is', await page.evaluate(() => document.querySelector('video.bc-preview').classList.contains('mirror'))); + await page.type('.go-live-title', 'Testing the live button'); + + console.log('\n[live]'); + await page.evaluate(() => document.querySelector('.go-live-start').click()); + check('starting opens the stream\'s page, with the clock and End', await waitFor(() => !!document.querySelector('.live-page .bc-bar .bc-end'), 40000)); + const ann = (await lives(page)).at(-1); + const tag = (e, k) => (e.tags.find((x) => x[0] === k) || [])[1]; + check('the stream is announced: a 30311 of yours, live, pointing at its HLS', !!ann && tag(ann, 'status') === 'live' && /\/live\/[0-9a-f]{16}\/live\.m3u8$/.test(tag(ann, 'streaming')) && tag(ann, 'title') === 'Testing the live button' + && ann.tags.some((x) => x[0] === 'p' && x[1] === ann.pubkey && x[3] === 'Host'), JSON.stringify(ann && ann.tags)); + check('...by the key the server let in', ann && ann.pubkey === owner); + check('the page shows you, not the stream seconds behind', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); return !!v && v.videoWidth > 0 && !document.querySelector('.live-page video.live-video:not(.bc-preview)'); }, 5000)); + const hls = tag(ann, 'streaming'); + const m3u8 = await (await fetch(hls)).text(); + check('the server is making HLS of it, three segments in when it\'s announced', (m3u8.match(/\.ts/g) || []).length >= 3 && !/ENDLIST/.test(m3u8), (m3u8.match(/\.ts/g) || []).length + ' segments'); + + console.log('\n[someone watching]'); + const viewer = await browser.newPage(); + await viewer.setViewport({ width: 390, height: 844 }); + await viewer.evaluateOnNewDocument(relayStub, [ann]); + const v = helpers(viewer); + await viewer.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: ann.pubkey, identifier: tag(ann, 'd') }), { waitUntil: 'domcontentloaded' }); + check('the stream\'s link opens it', await v.waitFor(() => !!document.querySelector('.live-page'), 15000) && await v.waitText('Testing the live button', 3000)); + check('...and it plays', await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000), + JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return x && { t: x.currentTime, w: x.videoWidth, err: x.error && x.error.code }; }))); + const st = await (await fetch(hls.replace('live.m3u8', 'stats'))).json(); + check('the server counts the watcher', st.viewers >= 1, JSON.stringify(st)); + await viewer.close(); + + console.log('\n[the end]'); + await page.evaluate(() => document.querySelector('.bc-end').click()); + check('End leaves the stream\'s page', await waitFor(() => !document.querySelector('.bc-bar'), 5000)); + check('...and lets go of the camera', await page.evaluate(() => !document.querySelector('video.bc-preview'))); + check('a last 30311 says it ended, with the recording', await waitFor(() => window.__published.some((e) => e.kind === 30311 && e.tags.some((x) => x[0] === 'status' && x[1] === 'ended') && e.tags.some((x) => x[0] === 'recording')), 8000)); + const fin = (await lives(page)).at(-1); + await sleep(1500); + const rec = await (await fetch(tag(fin, 'recording'))).text(); + check('...and the recording is whole', /ENDLIST/.test(rec) && (rec.match(/\.ts/g) || []).length >= 3, (rec.match(/\.ts/g) || []).length + ' segments'); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(true); names.stop(true); + await sleep(300); + if (!process.env.KEEP) rmSync(dir, { recursive: true, force: true }); else console.log('kept', dir); +} +console.log(ok ? '\n✅ going live: camera to stream to watcher' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/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-stream-test.js b/tools/live-stream-test.js new file mode 100644 index 0000000..88d2b17 --- /dev/null +++ b/tools/live-stream-test.js @@ -0,0 +1,208 @@ +// Live streams (NIP-53): a feed shows its people's streams in a row above +// the posts (a stream gone quiet for an hour is over), a tap opens the +// stream's page — the player playing the HLS stream, the chat and zaps as +// they come, a chat line sent as a kind 1311 — and the address bar names it +// (/naddr1…). A post linking the stream shows it as a card, the host's +// profile says they're on, Back stops the player, the browser without HLS +// of its own plays it through hls.js, and a zap carries the stream's `a`. +// Run: bun tools/live-stream-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { naddrEncode } from 'nostr-tools/nip19'; +import { buildZapRequest } from '../src/lnurl.js'; +import { setZapAddr } from '../src/live.js'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const PORT = 5302; +const now = Math.floor(Date.now() / 1000); +const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }; +const HOST = key(), SERVICE = key(), FAN = key(), STALE = key(); + +// a stream: 30 s of HLS, its playlist without an end (as a live one is) +const dir = mkdtempSync(tmpdir() + '/live-stream-'); +Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '50', '-c:a', 'aac', '-f', 'hls', '-hls_time', '2', '-hls_list_size', '0', dir + '/index.m3u8']); +writeFileSync(dir + '/index.m3u8', readFileSync(dir + '/index.m3u8', 'utf8').replace('#EXT-X-ENDLIST\n', '')); +const STREAM = `http://localhost:${PORT}/live/index.m3u8`; + +// zap.stream's shape: the service signs, the streamer is the Host +const live = finalizeEvent({ kind: 30311, created_at: now - 30, content: '', tags: [ + ['d', 'stream-1'], ['title', 'Building coinos live'], ['summary', 'Hacking on the wallet'], ['status', 'live'], + ['streaming', STREAM], ['current_participants', '12'], ['starts', String(now - 600)], + ['p', HOST.pk, '', 'Host'], ['relays', 'wss://relay.example.com'], +] }, SERVICE.sk); +const A = `30311:${SERVICE.pk}:stream-1`; +const NADDR = naddrEncode({ kind: 30311, pubkey: SERVICE.pk, identifier: 'stream-1' }); +// still says live, but nobody has refreshed it in two hours +const stale = finalizeEvent({ kind: 30311, created_at: now - 7200, content: '', tags: [ + ['d', 'old'], ['title', 'Forgotten stream'], ['status', 'live'], ['streaming', STREAM], ['p', HOST.pk, '', 'host'], +] }, STALE.sk); +const chat = finalizeEvent({ kind: 1311, created_at: now - 20, content: 'hello from the chat', tags: [['a', A, '', 'root']] }, FAN.sk); +const zapReq = finalizeEvent({ kind: 9734, created_at: now - 15, content: 'great stream', tags: [['p', HOST.pk], ['a', A], ['amount', '21000']] }, FAN.sk); +const zap = finalizeEvent({ kind: 9735, created_at: now - 15, content: '', tags: [['p', HOST.pk], ['a', A], ['bolt11', 'lnbc210n1invalid'], ['description', JSON.stringify(zapReq)], ['P', FAN.pk]] }, SERVICE.sk); +const post = finalizeEvent({ kind: 1, created_at: now - 100, content: 'come watch nostr:' + NADDR, tags: [] }, HOST.sk); +const FIXTURES = [live, stale, chat, zap]; +const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk); + +// the site as built (the split build: hls.js arrives as its own chunk) +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); +const server = Bun.serve({ port: PORT, fetch: async (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } }); + const f = Bun.file('dist' + path); + return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); + +// the relays: they hold the fixtures, answer by the filter, and keep what's published +const relayStub = (fx) => { + window.__published = []; window.__reqs = []; + const subs = []; + // a new event, to every open subscription it matches (a relay's live feed) + window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); }; + const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) + && (!f.since || e.created_at >= f.since) + && Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1]))); + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ' && m[2]) { + window.__reqs.push([a[0], ...m.slice(2)]); + subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, limit: undefined }))]); + const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e))); + if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0); + } + if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]); + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}; + +const newPage = async (noHls = false) => { + const page = await browser.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.evaluateOnNewDocument(relayStub, FIXTURES); + // Firefox: no HLS of its own + if (noHls) await page.evaluateOnNewDocument(() => { + const real = HTMLMediaElement.prototype.canPlayType; + HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : real.call(this, t); }; + }); + const errs = []; + page.on('pageerror', (e) => errs.push(String(e))); + page.errs = errs; + return page; +}; +const helpers = (page) => ({ + click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x), + waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }, + waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }, +}); +const playing = () => { const v = document.querySelector('video.live-video'); return !!v && !v.paused && v.currentTime > 1 && v.videoWidth > 0; }; + +try { + console.log('[a zap on a stream names it]'); + setZapAddr(live.id, A); + const req = await buildZapRequest({ amountMsat: 21000, relays: ['wss://r'], recipientPk: HOST.pk, eventId: live.id, signFn: (e) => e }); + check('the zap request carries the stream\'s a tag beside its e', req.tags.some((x) => x[0] === 'a' && x[1] === A) && req.tags.some((x) => x[0] === 'e' && x[1] === live.id)); + + const page = await newPage(); + const { click, waitText, waitFor } = helpers(page); + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive'); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Stream Host', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, HOST.pk, [post]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive'); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + + console.log('\n[the feed]'); + check('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 .live-tile'), 15000)); + const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' '))); + check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile)); + check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream')))); + 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 .live-tile'), 5000); + + console.log('\n[the stream\'s page]'); + await page.evaluate(() => document.querySelector('.live-grid .live-tile').click()); + check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname)); + check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; }))); + check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM)); + check('the chat shows what was said', await waitText('hello from the chat', 8000)); + check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000)); + await page.focus('.live-input'); + await page.keyboard.type('first time here'); + await page.keyboard.press('Enter'); + check('a line typed in the chat shows at once', await waitText('first time here', 3000)); + const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311)); + check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags)); + check('the player keeps playing through the renders', await waitFor(playing, 3000)); + + console.log('\n[leaving]'); + await page.goBack(); + check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000)); + check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected))); + await page.evaluate(() => document.querySelector('.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 says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000)); + check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected))); + check('no page errors', !page.errs.length, page.errs.join(' | ')); + await page.close(); + + console.log('\n[a browser without HLS of its own, opening a link]'); + const ff = await newPage(true); + const f = helpers(ff); + await ff.goto(`http://localhost:${PORT}/${NADDR}`, { waitUntil: 'domcontentloaded' }); + check('a /naddr1… link opens the stream, no wallet needed', await f.waitFor(() => !!document.querySelector('.live-page'), 15000) && await f.waitText('Building coinos live', 3000)); + check('hls.js plays it', await f.waitFor(playing, 25000), JSON.stringify(await ff.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, src: v.currentSrc.slice(0, 30), err: v.error && v.error.code }; }))); + check('...through Media Source', await ff.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:'))); + check('a visitor reads the chat', await f.waitText('hello from the chat', 8000)); + await ff.evaluate((e) => window.__push(e), ended); + check('the stream ending while watched says so, the player gone', await f.waitText('This stream has ended', 5000) && await ff.evaluate(() => !document.querySelector('video.live-video'))); + check('no page errors', !ff.errs.length, ff.errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); server.stop(true); + rmSync(dir, { recursive: true, force: true }); +} +console.log(ok ? '\n✅ live streams: found, watched, chatted in' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/media-servers-test.js b/tools/media-servers-test.js new file mode 100644 index 0000000..c5cad2f --- /dev/null +++ b/tools/media-servers-test.js @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { parseMediaServers, customMediaServers, mediaServers, saveMediaServers, resetMediaServers } from '../src/media-servers.js'; +import { uploadPublicMedia, uploadStream, PUBLIC_MEDIA_SERVERS } from '../src/media-upload.js'; +const stored = new Map(); +globalThis.localStorage = { getItem: k => stored.get(k) ?? null, setItem: (k, v) => stored.set(k, v), removeItem: k => stored.delete(k) }; +assert.equal(customMediaServers(), null); +assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS); +assert.deepEqual(saveMediaServers('https://FIRST.example/\nhttps://first.example https://second.example/'), ['https://first.example', 'https://second.example']); +for (const bad of ['', 'http://host.test', 'https://host.test/path', 'https://user:[email protected]', 'https://host.test?x=1', 'https://host.test#x', 'https://good.test nope', Array.from({length:7}, (_,i)=>`https://host${i}.test`).join('\n')]) { + assert.throws(() => saveMediaServers(bad)); + assert.equal(customMediaServers()[0], 'https://first.example', 'invalid input must not overwrite saved servers'); +} +const calls = []; +const fetcher = async (url, opts) => { + calls.push(url); + if (url.startsWith('https://first.')) return new Response('', {status:503}); + if (opts.method === 'HEAD') return new Response(''); + return Response.json({url: 'https://second.example/' + 'a'.repeat(64)}); +}; +const sign = async e => e; +await uploadPublicMedia(new File(['picture'], 'photo.png', {type:'image/png'}), sign, {fetcher}); +assert(calls.some(u => u.startsWith('https://second.'))); +assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u))); +calls.length = 0; +await uploadStream({master:'master.m3u8', files:new Map([ + ['video.mp4', new File(['video'], 'video.mp4', {type:'video/mp4'})], + ['master.m3u8', new File(['#EXTM3U\nvideo.mp4'], 'master.m3u8', {type:'application/vnd.apple.mpegurl'})], +])}, sign, {fetcher}); +assert(calls.every(u => /^https:\/\/(first|second)\.example\/upload$/.test(u))); +await assert.rejects(uploadPublicMedia(new File(['x'], 'x'), sign, {fetcher: async url => { + assert(calls.includes(url), 'must not fall back to public hosts'); + return new Response('', {status:503}); +}})); +// Exercise the attachment uploader used by DMs, communities and White Noise. +const source = await Bun.file('src/features/messages.js').text(); +const start = source.indexOf(' async function uploadEncrypted('); +const end = source.indexOf(' const imetaTag', start); +const encryptedCalls = []; +const uploadEncrypted = new Function('mediaServers', 'MEDIA_SERVERS', 'fetch', 't', + source.slice(start, end) + '\nreturn uploadEncrypted;')( + mediaServers, ['https://public.example'], async (url, init) => { + encryptedCalls.push({url, init}); + return Response.json({url: url.replace('/upload', '/cipher')}); + }, key => key); +const cipher = new Uint8Array([9, 8, 7]); +const urls = await uploadEncrypted({pubkey:'a'.repeat(64), signer:{signEvent: sign}}, {cipher, x:'b'.repeat(64)}); +assert.deepEqual(urls, ['https://first.example/cipher', 'https://second.example/cipher']); +assert(encryptedCalls.every(c => c.init.body === cipher && c.init.headers['content-type'] === 'application/octet-stream')); +resetMediaServers(); +assert.equal(customMediaServers(), null); +assert.deepEqual(mediaServers(PUBLIC_MEDIA_SERVERS), PUBLIC_MEDIA_SERVERS); +console.log('✓ Media server validation, persistence, custom-only file/HLS/encrypted attachment uploads, failover, and reset'); diff --git a/tools/negentropy-sync-test.js b/tools/negentropy-sync-test.js new file mode 100644 index 0000000..e0257ca --- /dev/null +++ b/tools/negentropy-sync-test.js @@ -0,0 +1,159 @@ +// Catch-up by reconciliation (NIP-77). A relay that speaks negentropy — +// relay.coinos.io does — is no longer asked to re-serve its newest events: +// the DM subscription opens live with limit 0 and the wraps we lack are +// fetched by id after a reconcile; the Notifications refresh reconciles +// its newest 120 against what we hold and fetches only the new ones, and +// a refresh holding everything fetches nothing. A relay that answers +// "negentropy disabled" (nos.lol) gets the plain subscription with its +// backlog, and is remembered as such for a day: the next ask there goes +// straight out as a REQ. +// Run: bun tools/negentropy-sync-test.js +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +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 key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }; +const FRIEND = key(), JOHNNY = key(), SENDER = key(); +const now = Math.floor(Date.now() / 1000); +const html = await buildHtml({ minify: true, pwa: false }); +// the client library plays the relay's side in the page +const negSrc = (await Bun.file('src/negentropy.js').text()).replace(/^export /gm, '') + '\nreturn { NegSet, Negentropy };'; +const server = Bun.serve({ port: 5337, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks') || path === '/verify-worker.js' || path.startsWith('/locales')) return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +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 text = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; }; +const sent = () => page.evaluate(() => window.__sent); +const rows = () => page.evaluate(() => [...document.querySelectorAll('.alert-row')].map((r) => r.innerText.replace(/\s+/g, ' ').trim())); +const host = (u) => u.replace(/^wss:\/\//, '').replace(/\/$/, ''); +const shape = (m) => { + const f = m[0] === 'REQ' ? m[2] : m[0] === 'NEG-OPEN' ? m[2] : null; + return m[0] + (f ? ' kinds=' + (f.kinds || []).join(',') + (f.ids ? ' ids=' + f.ids.length : '') + ('limit' in f ? ' limit=' + f.limit : '') + (f.since ? ' since=' + (f.since - Math.floor(Date.now() / 1000)) : '') : ''); +}; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5337/', { 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 ME = getPublicKey(privateKeyFromSeedWords(mn)); + const MINE = finalizeEvent({ kind: 1, created_at: now - 600, tags: [], content: 'my post, replied to' }, FRIEND.sk); + const reply = (who, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['e', MINE.id, '', 'root'], ['p', ME, '', 'mention']], content }, who.sk); + const NOTIFS = [reply(JOHNNY, 'johnny says hello there', 100), finalizeEvent({ kind: 7, created_at: now - 90, tags: [['e', MINE.id], ['p', ME]], content: '+' }, JOHNNY.sk), reply(FRIEND, 'a reply from a friend', 70)]; + // gift wraps to me (sealed to nobody: they will not open, which is beside the point here) + const WRAPS = [1, 2, 3].map((i) => finalizeEvent({ kind: 1059, created_at: now - i * 3600, tags: [['p', ME]], content: 'wrapped ' + i }, SENDER.sk)); + const PROFILES = [finalizeEvent({ kind: 0, created_at: now - 1000, tags: [], content: JSON.stringify({ name: 'Johnny' }) }, JOHNNY.sk), finalizeEvent({ kind: 0, created_at: now - 1000, tags: [], content: JSON.stringify({ name: 'Friend' }) }, FRIEND.sk)]; + await page.evaluateOnNewDocument((fx, negSrc) => { + const { NegSet, Negentropy } = new Function(negSrc)(); + window.__sent = []; + const Real = window.WebSocket; + const say = (ws, m) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(m) })); }; + const store = [...fx.notifs, ...fx.wraps, fx.mine, ...fx.profiles]; + const matches = (f) => store.filter((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) && (!f.until || e.created_at <= f.until) + && (!f['#p'] || e.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1]))) && (!f['#e'] || e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1])))); + const newest = (evs, f) => { const s = [...evs].sort((a, b) => b.created_at - a.created_at); return 'limit' in f ? s.slice(0, f.limit) : s; }; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const url = String(a[0]); + const coinos = /relay\.coinos\.io/.test(url); + const negs = new Map(); // subid -> relay side + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + window.__sent.push({ url, m }); + if (m[0] === 'REQ') { + const hit = newest(matches(m[2]), m[2]); + setTimeout(() => { for (const e of hit) say(ws, ['EVENT', m[1], e]); say(ws, ['EOSE', m[1]]); }, 30); + return; + } + if (m[0] === 'NEG-OPEN') { + if (!coinos) { setTimeout(() => say(ws, ['NOTICE', 'ERROR: bad msg: negentropy disabled']), 30); return; } + const items = newest(matches(m[2]), m[2]).map((e) => [e.created_at, e.id]); + const n = new Negentropy(new NegSet(items)); + negs.set(m[1], n); + n.reconcile(m[3]).then((r) => say(ws, ['NEG-MSG', m[1], r.next])); + return; + } + if (m[0] === 'NEG-MSG') { const n = negs.get(m[1]); if (n) n.reconcile(m[2]).then((r) => say(ws, ['NEG-MSG', m[1], r.next])); return; } + if (m[0] === 'NEG-CLOSE') { negs.delete(m[1]); return; } + if (m[0] === 'EVENT') { setTimeout(() => say(ws, ['OK', m[1].id, true, '']), 0); return; } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); + }, { me: ME, mine: MINE, notifs: NOTIFS, wraps: WRAPS, profiles: PROFILES }, negSrc); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + + console.log('\n[the DM subscription]'); + await waitFor(() => window.__sent.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length), null, 15000); + await sleep(500); + let log = await sent(); + const at = (re) => log.filter((x) => re.test(x.url)).map((x) => shape(x.m)); + const coinosDm = at(/coinos/).filter((s) => /kinds=1059/.test(s)); + check('relay.coinos.io: the live wrap subscription opens with limit 0', coinosDm.some((s) => s === 'REQ kinds=1059 limit=0'), coinosDm.join(' | ')); + check('…and a reconcile of the newest 400', coinosDm.some((s) => s === 'NEG-OPEN kinds=1059 limit=400')); + check('…the relay is never asked to re-serve its backlog', !coinosDm.some((s) => /^REQ kinds=1059 limit=400/.test(s))); + const idsAsk = log.find((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length); + check('…and the wraps we lack are fetched by id', !!idsAsk && WRAPS.every((w) => idsAsk.m[2].ids.includes(w.id)), idsAsk ? idsAsk.m[2].ids.length + ' ids' : 'no ask by id'); + const nosDm = at(/nos\.lol/).filter((s) => /kinds=1059/.test(s)); + check('nos.lol says negentropy is disabled: it gets the plain subscription, backlog and all', nosDm.includes('NEG-OPEN kinds=1059 limit=400') && nosDm.includes('REQ kinds=1059 limit=400'), nosDm.join(' | ')); + check('…and is remembered as such', await page.evaluate(() => /nos\.lol/.test(localStorage.getItem('coinos-neg-off') || ''))); + + console.log('\n[Notifications]'); + const before = log.length; + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click()); + let got = []; + for (let i = 0; i < 60; i++) { got = await rows(); if (got.length >= 3) break; await sleep(250); } + check('the three notifications show', got.length === 3, got.join(' | ')); + log = (await sent()).slice(before); + const coinosN = log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m)).filter((s) => /kinds=1,/.test(s) || /ids=/.test(s)); + console.log(' coinos:', log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m) + (x.m[0] === 'REQ' && x.m[2].ids ? ':' + x.m[2].ids.map((i) => i.slice(0, 6)).join(',') : '') + (x.m[0] === 'NEG-MSG' ? ' ' + x.m[2].length + 'ch' : '')).join(' | ')); + console.log(' notif ids:', NOTIFS.map((n) => n.id.slice(0, 6)).join(','), 'mine', MINE.id.slice(0, 6)); + check('relay.coinos.io: reconciled, its newest 120', coinosN.some((s) => /^NEG-OPEN kinds=1,1111,6,7.* limit=120$/.test(s)), coinosN.join(' | ')); + check('…not asked for them outright', !coinosN.some((s) => /^REQ kinds=1,1111,6,7.* limit=120$/.test(s))); + // (the first refresh ran at boot, for the unread count: that is the one that fetched them) + const all = await sent(); + const nAsk = all.find((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).length && NOTIFS.some((n) => x.m[2].ids.includes(n.id))); + check('…the three we lacked fetched by id, once, at the boot refresh', !!nAsk && nAsk.m[2].ids.length === 3 && all.indexOf(nAsk) < before, nAsk ? nAsk.m[2].ids.length + ' ids at ' + all.indexOf(nAsk) + ' (page opened at ' + before + ')' : 'none'); + check('…and the page\'s own refresh, holding them, fetched nothing', !log.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).some((id) => NOTIFS.some((n) => n.id === id)))); + const nosN = log.filter((x) => /nos\.lol/.test(x.url)).map((x) => shape(x.m)); + check('nos.lol: the plain query, straight out, no reconcile tried', nosN.some((s) => /^REQ kinds=1,1111,6,7.* limit=120$/.test(s)) && !nosN.some((s) => s.startsWith('NEG-OPEN')), nosN.join(' | ')); + + console.log('\n[a refresh holding everything, after a reload]'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + const b2 = (await sent()).length; + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click()); + for (let i = 0; i < 60; i++) { got = await rows(); if (got.length >= 3) break; await sleep(250); } + check('the notifications are there from the cache', got.length === 3, got.join(' | ')); + await waitFor((n) => window.__sent.slice(n).some((x) => /coinos/.test(x.url) && x.m[0] === 'NEG-CLOSE'), b2, 10000); + await sleep(800); + log = (await sent()).slice(b2); + const again = log.filter((x) => /coinos/.test(x.url)).map((x) => shape(x.m)).filter((s) => /kinds=1,/.test(s) || /ids=/.test(s)); + check('relay.coinos.io reconciled again…', again.some((s) => /^NEG-OPEN kinds=1,1111,6,7.* limit=120$/.test(s)), again.join(' | ')); + check('…and nothing was fetched: we held it all', !log.some((x) => /coinos/.test(x.url) && x.m[0] === 'REQ' && (x.m[2].ids || []).some((id) => NOTIFS.some((n) => n.id === id)))); + 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✅ catch-up by reconciliation where the relay speaks it, the plain query where it does not' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/negentropy-test.js b/tools/negentropy-test.js new file mode 100644 index 0000000..28a1f30 --- /dev/null +++ b/tools/negentropy-test.js @@ -0,0 +1,64 @@ +// The negentropy client (NIP-77, src/negentropy.js) on the wire as strfry +// and the reference implementation speak it: the opening message for a +// fixed set is byte-identical to the reference's (checked against +// hoytech/negentropy's JS on 2026-10-16: seven pairings of ours and theirs +// as client and relay agreed on every have/need), and a reconciliation +// against our own relay side finds exactly what each side lacks — in one +// round for a small difference, in several when the relay's frame is +// small — and ends with nothing to say once the sets match. +// Run: bun tools/negentropy-test.js +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { NegSet, Negentropy } from '../src/negentropy.js'; +const id = (n) => createHash('sha256').update('item-' + n).digest('hex'); +const sha = (s) => createHash('sha256').update(s).digest('hex'); + +// the fixed sets the reference was run on +const A = Array.from({ length: 120 }, (_, i) => [1000 + Math.floor(i / 3), id(i)]); +const B = [...A.filter((_, i) => i % 17 !== 0), ...Array.from({ length: 9 }, (_, i) => [1000 + i * 4, id(1000 + i)])]; +const first = await new Negentropy(new NegSet(A)).initiate(); +assert.equal(first.length, 634, 'the opening message for the fixed set is 317 bytes'); +assert.equal(first.slice(0, 2), '61', 'protocol version 1'); +assert.equal(sha(first), 'c926c013652f6e2f727abff15c0838aebd7f613fb4749ee691952dfc8be8ff81', 'the opening message is the reference implementation\'s, byte for byte'); + +async function talk(client, relay) { + let msg = await client.initiate(), rounds = 0; + const have = [], need = []; + while (msg) { + rounds++; + const r = await relay.reconcile(msg); + assert(r.next, 'the relay side always answers'); + const c = await client.reconcile(r.next); + have.push(...c.have); need.push(...c.need); msg = c.next; + } + return { rounds, have: have.sort(), need: need.sort() }; +} +{ + const r = await talk(new Negentropy(new NegSet(A)), new Negentropy(new NegSet(B))); + assert.equal(r.rounds, 1, 'a small difference reconciles in one round'); + assert.deepEqual(r.have, A.filter((_, i) => i % 17 === 0).map((x) => x[1]).sort(), 'have: the ids the relay lacks'); + assert.deepEqual(r.need, Array.from({ length: 9 }, (_, i) => id(1000 + i)).sort(), 'need: the ids we lack'); +} +{ + const r = await talk(new Negentropy(new NegSet(A)), new Negentropy(new NegSet(A))); + assert.equal(r.rounds, 1); assert.deepEqual(r.have, []); assert.deepEqual(r.need, []); + const r2 = await talk(new Negentropy(new NegSet([])), new Negentropy(new NegSet(B))); + assert.equal(r2.need.length, B.length, 'holding nothing, we need everything'); + const r3 = await talk(new Negentropy(new NegSet(B)), new Negentropy(new NegSet([]))); + assert.equal(r3.have.length, B.length, 'the relay holding nothing lacks everything'); +} +{ + // a big set and a relay with a small frame: many rounds, the same answer + const big = Array.from({ length: 3000 }, (_, i) => [1000 + i, id(i)]); + const theirs = [...big.filter((_, i) => i % 101 !== 0), ...Array.from({ length: 25 }, (_, i) => [1000 + i * 7, id(5000 + i)])]; + const r = await talk(new Negentropy(new NegSet(big)), new Negentropy(new NegSet(theirs), { frameSizeLimit: 4096 })); + assert(r.rounds > 1, 'a small frame takes several rounds: ' + r.rounds); + assert.equal(r.have.length, 30); assert.equal(r.need.length, 25); +} +{ + // the set drops duplicates and bad ids, and sorts by time then id + const s = new NegSet([[5, id(1)], [5, id(1)], [3, 'nope'], [4, id(2)], [5, id(0)]]); + assert.equal(s.size, 3); + assert.deepEqual(s.items.map((x) => x.ts), [4, 5, 5]); +} +console.log('✓ negentropy: the reference\'s bytes, one round for a small difference, several for a small frame, nothing to say when equal'); diff --git a/tools/nwc-budget-test.js b/tools/nwc-budget-test.js new file mode 100644 index 0000000..780f5ea --- /dev/null +++ b/tools/nwc-budget-test.js @@ -0,0 +1,52 @@ +// The NWC budget window: a UTC day, week (Monday) or month; what a +// connection spent in the window on now, counting the app's counter and the +// worker's unabsorbed spends; when it renews. Run: bun tools/nwc-budget-test.js +import { budgetWindow, budgetRenewsAt, renewalPeriod, spentInWindow } from '../src/nwc-budget.js'; + +let fails = 0; +const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!ok) fails++; }; + +// Wednesday 2026-10-14 23:30 UTC +const T = Date.UTC(2026, 9, 14, 23, 30); +check('day window is the UTC date', budgetWindow('day', T) === '2026-10-14', budgetWindow('day', T)); +check('day: half an hour later is the next day', budgetWindow('day', T + 3600_000) === '2026-10-15'); +check('week window is keyed by its Monday', budgetWindow('week', T) === 'W2026-10-12', budgetWindow('week', T)); +check('week: Sunday night is still that week', budgetWindow('week', Date.UTC(2026, 9, 18, 23, 59)) === 'W2026-10-12'); +check('week: Monday 00:00 starts the next', budgetWindow('week', Date.UTC(2026, 9, 19, 0, 0)) === 'W2026-10-19'); +check('month window is the UTC month', budgetWindow('month', T) === '2026-10'); +check('month: Nov 1 00:00 is the next', budgetWindow('month', Date.UTC(2026, 10, 1)) === '2026-11'); +check('unknown period counts as a day', budgetWindow(undefined, T) === '2026-10-14'); + +check('day renews at next midnight', budgetRenewsAt('day', T) === Date.UTC(2026, 9, 15) / 1000); +check('week renews next Monday', budgetRenewsAt('week', T) === Date.UTC(2026, 9, 19) / 1000); +check('month renews on the 1st', budgetRenewsAt('month', T) === Date.UTC(2026, 10, 1) / 1000); +check('December rolls into January', budgetRenewsAt('month', Date.UTC(2026, 11, 20)) === Date.UTC(2027, 0, 1) / 1000); +check('NIP-47 period words', renewalPeriod('day') === 'daily' && renewalPeriod('week') === 'weekly' && renewalPeriod('month') === 'monthly' && renewalPeriod(undefined) === 'daily'); + +console.log('\n[spent in the window]'); +const conn = { id: 'c1', dailySat: 10000, period: 'month', spentDate: '2026-10', spentToday: 3000 }; +{ + const r = spentInWindow(conn, [], T); + check('the app counter counts when stamped with this window', r.spent === 3000 && r.left === 7000, JSON.stringify(r)); +} +{ + const r = spentInWindow({ ...conn, spentDate: '2026-09' }, [], T); + check('a counter from last month does not', r.spent === 0 && r.left === 10000); +} +{ + const spends = [ + { connId: 'c1', amountSat: 500, feeSat: 2, ts: Date.UTC(2026, 9, 2) }, // this month, unabsorbed + { connId: 'c1', amountSat: 400, feeSat: 0, ts: Date.UTC(2026, 9, 3), absorbed: true }, // already in the counter + { connId: 'c1', amountSat: 900, feeSat: 0, ts: Date.UTC(2026, 8, 30) }, // last month + { connId: 'c2', amountSat: 800, feeSat: 0, ts: Date.UTC(2026, 9, 5) }, // another connection + ]; + const r = spentInWindow(conn, spends, T); + check('the worker adds only its own, unabsorbed spends from this window', r.spent === 3502, String(r.spent)); +} +{ + const r = spentInWindow({ id: 'c1', dailySat: 100, spentDate: '2026-10-14', spentToday: 80 }, [{ connId: 'c1', amountSat: 30, ts: T - 60_000 }], T); + check('a day budget, over: left is 0 not negative', r.spent === 110 && r.left === 0); +} + +console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ budget windows behave'); +process.exit(fails ? 1 : 0); diff --git a/tools/nwc-settings-ui-test.js b/tools/nwc-settings-ui-test.js new file mode 100644 index 0000000..b88f9f7 --- /dev/null +++ b/tools/nwc-settings-ui-test.js @@ -0,0 +1,80 @@ +// Settings → Nostr → Nostr Wallet Connect: a connection is made with a +// budget that resets every day, week or month; the card says so, and +// Revoke keeps it (greyed out of the list) answering for a while. +// Run: bun tools/nwc-settings-ui-test.js (needs the regtest ark stack on :3535 for the card to show) +import puppeteer from './puppeteer.js'; +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 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'] }); +await browser.defaultBrowserContext().overridePermissions(server.url.origin, ['notifications']); +const page = await browser.newPage(); +// a headless Chrome has no push service: make notifications look allowed so +// the connection is created (the app creates it anyway when permission is +// granted and the plumbing fails), without the real notifier in the picture +await page.evaluateOnNewDocument(() => { + try { Object.defineProperty(Notification, 'permission', { get: () => 'granted' }); Notification.requestPermission = async () => 'granted'; } catch {} + // no service worker in this build: 'ready' would never resolve and the + // Create button would hang on it. Stand in one whose push subscribe fails + // the way a browser without a push service does. + try { + Object.defineProperty(navigator.serviceWorker, 'ready', { get: () => Promise.resolve({ + pushManager: { subscribe: async () => { throw new Error('Registration failed - push service error'); }, getSubscription: async () => null }, + showNotification: async () => {}, + }) }); + } catch {} + const f = window.fetch; + window.fetch = (u, o) => (String(u).includes('nwcpush') ? Promise.reject(new Error('no notifier in the test')) : f(u, o)); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const logs = []; +page.on('console', (m) => logs.push(m.text())); +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 text = () => page.evaluate(() => document.body.innerText); +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + 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'); + await click('skip verification'); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600); + await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800); + check('the Wallet Connect card is there', await waitText('nostr wallet connect', 15000)); + await click('add a connection'); await sleep(400); + check('the form offers a budget period', await page.evaluate(() => { const s = document.querySelector('select.nwc-period'); return !!s && [...s.options].map((o) => o.value).join() === 'day,week,month' && s.value === 'day'; })); + check('…and says when it resets', (await text()).includes('midnight UTC')); + await page.type('input[placeholder*="App name"]', 'PullCap'); + await page.evaluate(() => { const s = document.querySelector('select.nwc-period'); s.value = 'month'; s.dispatchEvent(new Event('change', { bubbles: true })); }); + await page.evaluate(() => { const i = [...document.querySelectorAll('label.field')].find((l) => l.textContent.includes('Budget (sats)')).querySelector('input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); }); + await page.evaluate(() => { const i = [...document.querySelectorAll('label.field')].find((l) => l.textContent.includes('Budget (sats)')).querySelector('input'); i.value = '21000'; i.dispatchEvent(new Event('input', { bubbles: true })); }); + await click('create connection'); + const created = await waitText('pullcap', 10000); + if (!created) console.log('DEBUG env:', await page.evaluate(() => ({ push: 'PushManager' in window, sw: 'serviceWorker' in navigator, perm: Notification.permission, toast: document.querySelector('.toast, [class*=toast]')?.textContent })), '\nDEBUG logs:', logs.slice(-10).join('\n')); + check('the connection is created', created); + const line = (await text()).split('\n').find((l) => /\/payment/.test(l)) || ''; + check('the card reads the budget as monthly', /21,000 a month · 0 spent this month/.test(line), line); + const st = await page.evaluate(() => { const k = Object.keys(localStorage).find((x) => x.endsWith(':nwc') || x.includes('nwc')); return k; }); + const conn = await page.evaluate(() => { for (const k of Object.keys(localStorage)) { try { const v = JSON.parse(localStorage.getItem(k)); const c = v && v.conns && v.conns[0]; if (c && c.name === 'PullCap') return c; } catch {} } return null; }); + check('stored with period month and the budget', conn && conn.period === 'month' && conn.dailySat === 21000, JSON.stringify(conn && { period: conn.period, dailySat: conn.dailySat })); + await page.evaluate(() => { const e = [...document.querySelectorAll('.linklike')].find((n) => n.textContent.trim() === 'Revoke'); e.click(); }); await sleep(600); + const after = await page.evaluate(() => { for (const k of Object.keys(localStorage)) { try { const v = JSON.parse(localStorage.getItem(k)); const c = v && v.conns && v.conns[0]; if (c && c.name === 'PullCap') return c; } catch {} } return null; }); + check('Revoke dates the revocation', after && after.revoked === true && typeof after.revokedAt === 'number' && Date.now() - after.revokedAt < 60_000, JSON.stringify(after && { revoked: after.revoked, revokedAt: after.revokedAt })); + check('…and the connection leaves the list', await page.evaluate(() => ![...document.querySelectorAll('.linklike')].some((n) => n.textContent.trim() === 'Revoke') && ![...document.querySelectorAll('button')].some((b) => b.textContent.includes('Show connection string')))); + await page.screenshot({ path: process.env.SHOT || '/tmp/claude-1000/-home-adam-coinosv3/a8722639-d1b4-4c63-af48-8537d47913b1/scratchpad/nwc-settings.png' }); + 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✅ budget periods in the settings card' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 62248a4..e3bdb7b 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -239,5 +239,136 @@ console.log('\n[sibling record hygiene]'); check('stale record keeps its coin state', saved.wStale.mgr.vtxos.length === 1); } + +console.log('\n[period budgets + get_budget]'); +{ + const { budgetWindow } = await import('../src/nwc-budget.js'); + // a monthly budget: the app's counter (mirrored, stamped with this month) plus the worker's own spends count + const rec = baseRec(); + rec.connections = [{ ...conn, period: 'month', dailySat: 2000, spentDate: budgetWindow('month'), spentToday: 1500 }]; + rec.spends = [{ connId: 'c1', amountSat: 490, feeSat: 0, ts: Date.now() }]; + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + const r = decryptReply(published[0]); + check('app counter + worker spends over the monthly budget → refused, naming the period', handled && r.error?.code === 'QUOTA_EXCEEDED' && /monthly budget \(10 sat\)/.test(r.error.message), JSON.stringify(r.error)); +} +{ + const { budgetWindow } = await import('../src/nwc-budget.js'); + const rec = baseRec(); + // a counter from LAST month and a worker spend from last month: neither counts this month + const lastMonth = new Date(); lastMonth.setUTCMonth(lastMonth.getUTCMonth() - 1); + rec.connections = [{ ...conn, period: 'month', dailySat: 2000, spentDate: budgetWindow('month', lastMonth.getTime()), spentToday: 1999 }]; + rec.spends = [{ connId: 'c1', amountSat: 1999, feeSat: 0, ts: lastMonth.getTime() }]; + rec.mgr.vtxos = []; // so the pay stops at "empty mirror" right after the budget check + const { deps, published } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('last month\'s spending does not count this month (budget passed, mirror empty → wake)', handled === false && published.length === 0); +} +{ + const { budgetWindow, budgetRenewsAt } = await import('../src/nwc-budget.js'); + const rec = baseRec(); + rec.connections = [{ ...conn, period: 'week', dailySat: 7000, spentDate: budgetWindow('week'), spentToday: 1000 }]; + rec.spends = [{ connId: 'c1', amountSat: 200, feeSat: 1, ts: Date.now() }]; + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('get_budget')) }, deps); + const r = decryptReply(published[0]); + check('get_budget: used, total, renews_at and the period, in msat', handled && r.result?.used_budget === 1201000 && r.result?.total_budget === 7000000 && r.result?.renewal_period === 'weekly' && r.result?.renews_at === budgetRenewsAt('week'), JSON.stringify(r)); + const { deps: d2, published: p2, decryptReply: dr2 } = harness(); + await respondFromBg({ type: 'nwc', event: ev(makeReq('get_info')) }, d2); + check('get_info lists get_budget', dr2(p2[0]).result.methods.includes('get_budget')); +} + +console.log('\n[revoked connection]'); +{ + const rec = baseRec(); + rec.connections = [{ ...conn, revoked: true }]; + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + const r = decryptReply(published[0]); + check('a pay on a revoked connection is refused UNAUTHORIZED, not ignored', handled === true && r.error?.code === 'UNAUTHORIZED' && r.result_type === 'pay_invoice', JSON.stringify(r)); + const { deps: d2, published: p2, decryptReply: dr2 } = harness({ rec }); + await respondFromBg({ type: 'nwc', event: ev(makeReq('get_balance', {}, 'nip04')) }, d2); + check('…so is a balance ask, in the asker\'s scheme', dr2(p2[0]).error?.code === 'UNAUTHORIZED' && p2[0].tags.find((t) => t[0] === 'encryption')[1] === 'nip04'); +} + +console.log('\n[the mirror carries what the worker needs]'); +{ + const { buildBg } = await import('../src/nwc-bg.js'); + const keyFor = (chain, i) => `${chain}:${i}`.padEnd(8, '0'); + const mgrState = { + nextKeyIndex: 40, nextLnRecvIndex: 0, + vtxos: [{ id: 'v1', keyIndex: 7, state: 'spendable' }, { id: 'v0', keyIndex: 3, state: 'spent' }], + scheduled: [{ unlockHash: 'u1', inputIds: ['v1'], outKeyIndex: 12 }], + actions: [{ type: 'refresh', step: 'submitted', outKeyIndex: 15 }, { type: 'refresh', step: 'done', outKeyIndex: 16 }], + }; + const rec = buildBg({ ark: {}, mgrState, keyFor, connections: [{ ...conn, period: 'month', spentDate: '2026-10', spentToday: 77, revoked: true }] }); + check('a standing order\'s output key travels', rec.keys['12'] === keyFor(3, 12)); + check('a renewal in flight\'s output key travels', rec.keys['15'] === keyFor(3, 15)); + check('a finished renewal\'s does not', !rec.keys['16']); + check('the spent coin\'s key does not, the live one\'s does', !rec.keys['3'] && !!rec.keys['7']); + const c = rec.connections[0]; + check('period, the spent counter and the revocation ride along', c.period === 'month' && c.spentDate === '2026-10' && c.spentToday === 77 && c.revoked === true); + check('…and the budget period defaults to a day', buildBg({ ark: {}, mgrState, keyFor, connections: [conn] }).connections[0].period === 'day'); +} +{ + const { ArkManager } = await import('../src/ark/manager.js'); + const m = ArkManager.mergeScheduled([{ unlockHash: 'a' }, { unlockHash: 'b' }], [{ unlockHash: 'b' }, { unlockHash: 'c' }, { inputIds: [] }]); + check('standing orders merge by unlock hash', m.map((s) => s.unlockHash).join() === 'a,b,c', JSON.stringify(m)); +} + +console.log('\n[tending renewals while closed]'); +{ + const { tendRenewals } = await import('../src/nwc-respond.js'); + const { ArkManager } = await import('../src/ark/manager.js'); + const calls = []; + const orig = { init: ArkManager.prototype.init, tip: ArkManager.prototype._tipMemo, drive: ArkManager.prototype._driveScheduled, ensure: ArkManager.prototype.ensureScheduledRefreshes, refresh: ArkManager.prototype._driveRefresh }; + ArkManager.prototype.init = async function () { this.state = this.storage.load(); calls.push(['init', this.network]); return this; }; + ArkManager.prototype._tipMemo = async function () { calls.push(['tip']); return 100; }; + ArkManager.prototype._driveScheduled = async function () { + calls.push(['drive', (this.state.scheduled || []).length]); + // the round for one order ran: it is promoted — out of scheduled, into a refresh action in flight + const ran = (this.state.scheduled || []).find((s) => s.unlockHash === 'ran'); + this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== ran); + if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash }); + }; + ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; }; + ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max) { calls.push(['ensure', margin, max]); return 1; }; + try { + const rec = baseRec(); + rec.key4 = 'ff'.repeat(32); + rec.mgr.nextKeyIndex = 5; + rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window + rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }]; + const saved = []; + const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} }); + check('claims what ran, then covers what is bare: orders limited to the key window', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2]]), JSON.stringify(calls)); + check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1')); + check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done'); + // a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next + calls.length = 0; + const rec5 = baseRec(); rec5.key4 = 'ff'.repeat(32); rec5.keys = { 1: 'aa' }; + rec5.mgr.actions = [{ id: 'r2', type: 'refresh', step: 'issued' }, { id: 'r3', type: 'refresh', step: 'done' }, { id: 's1', type: 'send', step: 'created' }]; + const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} }); + check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls)); + + calls.length = 0; + const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key + await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} }); + check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls)); + + calls.length = 0; + const old = baseRec(); // no key4: a mirror from before invoices — can't sign for the mailbox + await tendRenewals({ recordsFn: async () => [{ walletKey: 'w3', rec: old }], saveFn: async () => {} }); + check('an old mirror is left alone', calls.length === 0); + + calls.length = 0; + const rec4 = baseRec(); rec4.key4 = 'ff'.repeat(32); + await tendRenewals({ recordsFn: async () => [{ walletKey: 'w4', rec: rec4 }], saveFn: async () => {}, deadline: Date.now() - 1 }); + check('out of push budget → nothing started', calls.length === 0); + } finally { + Object.assign(ArkManager.prototype, { init: orig.init, _tipMemo: orig.tip, _driveScheduled: orig.drive, ensureScheduledRefreshes: orig.ensure, _driveRefresh: orig.refresh }); + } +} + console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ background responder behaves'); process.exit(fails ? 1 : 0); diff --git a/tools/nwc-test.js b/tools/nwc-test.js index 848b2c6..5b0dd10 100644 --- a/tools/nwc-test.js +++ b/tools/nwc-test.js @@ -13,12 +13,13 @@ const store = {}; globalThis.localStorage = { getItem:k=>k in store?store[k]:null, setItem:(k,v)=>{store[k]=v;}, removeItem:k=>{delete store[k];} }; let subHandler = null; +const subFilters = []; // every filter subscribed, in order let priorReplies = []; let claimOk = true; const published = []; // fake relay transport: capture the subscription handler and any replies const nwcTransport = { - subscribe: (relays, filter, on) => { subHandler = on; return () => { subHandler = null; }; }, + subscribe: (relays, filter, on) => { subFilters.push(filter); subHandler = on; return () => { subHandler = null; }; }, publish: async (relays, evt) => { published.push(evt); return true; }, query: async () => priorReplies, // what other devices have answered (none, unless a test says so) errGraceMs: 20, // the sibling-first hold, shortened for the harness @@ -222,5 +223,68 @@ console.log('\n[device sync of connections]'); check('what we publish carries the connections', Array.isArray(ext.save().nwcConns)); } + +// --------------------------------------------------------------------------- +console.log('\n[period budgets + get_budget]'); +{ + const { budgetWindow, budgetRenewsAt } = await import('../src/nwc-budget.js'); + const st = JSON.parse(store['fs:nwc']); + const c = st.conns.find((x) => x.id === 'test1'); + Object.assign(c, { period: 'month', dailySat: 2000, spentDate: budgetWindow('month'), spentToday: 1990 }); + store['fs:nwc'] = JSON.stringify(st); + f.stop(); f.init(); priorReplies = []; claimOk = true; + const wasPaid = paid.length; + r = await request('pay_invoice', { invoice: 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g' }); + check('21 sat over a monthly budget with 10 left → refused, naming the month', r?.error?.code === 'QUOTA_EXCEEDED' && /monthly budget \(10 sat\)/.test(r?.error?.message || ''), JSON.stringify(r?.error)); + check('nothing was paid', paid.length === wasPaid); + r = await request('get_budget', {}); + check('get_budget reports the month', r?.result?.used_budget === 1990000 && r?.result?.total_budget === 2000000 && r?.result?.renewal_period === 'monthly' && r?.result?.renews_at === budgetRenewsAt('month'), JSON.stringify(r)); + r = await request('get_info', {}); + check('get_info lists get_budget', r?.result?.methods?.includes('get_budget')); + // last month's counter is not this month's + const st2 = JSON.parse(store['fs:nwc']); + const lm = new Date(); lm.setUTCMonth(lm.getUTCMonth() - 1); + Object.assign(st2.conns.find((x) => x.id === 'test1'), { spentDate: budgetWindow('month', lm.getTime()), spentToday: 1990 }); + store['fs:nwc'] = JSON.stringify(st2); + f.stop(); f.init(); + r = await request('get_budget', {}); + check('a counter from last month reads as nothing spent', r?.result?.used_budget === 0, JSON.stringify(r?.result)); + r = await request('pay_invoice', { invoice: INV21 }); // the mock seam pays 25 + 1 fee + check('…and a payment goes through, stamped with this month', !!r?.result?.preimage && JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentDate === budgetWindow('month') && JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentToday === 26, JSON.stringify(r?.error || r)); +} + +console.log('\n[revoked: refused out loud, for a while]'); +{ + const st = JSON.parse(store['fs:nwc']); + const c = st.conns.find((x) => x.id === 'test1'); + c.revoked = true; c.revokedAt = Date.now(); + store['fs:nwc'] = JSON.stringify(st); + subFilters.length = 0; + f.stop(); f.init(); + check('a lately revoked connection is still listened for', subFilters.some((x) => x.kinds?.[0] === 23194 && x['#p']?.[0] === conn.servicePk)); + const wasPaid = paid.length; + r = await request('pay_invoice', { invoice: INV21 }); + check('a pay is answered UNAUTHORIZED', r?.error?.code === 'UNAUTHORIZED' && r?.result_type === 'pay_invoice', JSON.stringify(r)); + check('nothing was paid', paid.length === wasPaid); + r = await request('get_balance', {}, 'nip04'); + check('a balance ask too, in nip04', r?.error?.code === 'UNAUTHORIZED' && r?.result_type === 'get_balance', JSON.stringify(r)); + const n = published.filter((e) => e.kind === 13194 && e.pubkey === conn.servicePk).length; + f.stop(); f.init(); await new Promise((r) => setTimeout(r, 100)); + check('no capability event is published for it', published.filter((e) => e.kind === 13194 && e.pubkey === conn.servicePk).length === n); + check('the revocation date travels in the snapshot', wallet.__ext.save().nwcConns.find((x) => x.id === 'test1').revokedAt === c.revokedAt); + // 31 days on: forgotten + const st2 = JSON.parse(store['fs:nwc']); + st2.conns.find((x) => x.id === 'test1').revokedAt = Date.now() - 31 * 86_400_000; + store['fs:nwc'] = JSON.stringify(st2); + subFilters.length = 0; + f.stop(); f.init(); + check('after the grace period it is no longer listened for', !subFilters.some((x) => x.kinds?.[0] === 23194 && x['#p']?.[0] === conn.servicePk)); + // a revocation dated on two devices keeps the earlier date + const ext = wallet.__ext; + const stale = { ...JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1'), revokedAt: Date.now() - 40 * 86_400_000 }; + ext.load({ nwcConns: [stale] }); + check('merge keeps the earlier revocation date', JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').revokedAt === stale.revokedAt); +} + console.log(ok ? '\n✅ NWC service behaves' : '\n❌ failed'); process.exit(ok?0:1); diff --git a/tools/pager-caption-test.js b/tools/pager-caption-test.js new file mode 100644 index 0000000..5952d81 --- /dev/null +++ b/tools/pager-caption-test.js @@ -0,0 +1,134 @@ +// The video pager's caption is the post's words with their names, tags and +// links live: a tap on @someone leaves the pager and opens their profile +// (a plain excerpt left "@NostrValley" dead); a tap anywhere else on the +// caption leaves the pager and opens the post, where the whole caption +// reads. A link to the web opens in its own tab and the pager stays. +// Run: bun tools/pager-caption-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { npubEncode } from 'nostr-tools/nip19'; +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 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 f; +}; +const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320') }; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), VALLEY = getPublicKey(generateSecretKey()); +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; + const m = /^\/(wide|tall)\.webm$/.exec(path); + if (m) return new Response(Bun.file(CLIPS[m[1]]), { headers: { 'content-type': 'video/webm' } }); + 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) => server.url.origin + '/' + n + '.webm'; +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const seeded = [ + post('Opening night nostr:' + npubEncode(VALLEY) + ' downtown entertainment, a long night of it with music and more ' + U('wide'), 100), + post('a tall one https://example.com/about ' + U('tall'), 200), + ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4), 600 + i * 60)), +]; + +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.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); + window.__opened = []; + window.open = (u) => { window.__opened.push(String(u)); return null; }; +}); +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 openClip = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); + await sleep(400); + const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + await page.mouse.click(at.x, at.y); + const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000); + // the caption and the counts show on a tap on the clip (details), as a reader sees them + await sleep(500); + await page.mouse.click(195, 300); + await waitFor(() => !!document.querySelector('.media-pager.show'), null, 3000); + return opened; +}; +// the caption of the slide on screen +const CAP = () => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => Math.abs(x.getBoundingClientRect().top) < 4); return s && s.querySelector('.mp-caption'); }; +const caption = () => page.evaluate((cap) => { const c = eval(cap)(); return c ? { text: c.innerText, links: [...c.querySelectorAll('a')].map((a) => a.textContent) } : null; }, '(' + CAP + ')'); +const clickIn = async (sel, where) => { + const at = await page.evaluate(([cap, sel, where]) => { + const c = eval(cap)(); const el = sel ? c.querySelector(sel) : c; const r = el.getBoundingClientRect(); + // the caption's first word sits at its left edge; a link in the middle + return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }, ['(' + CAP + ')', sel, where]); + await page.mouse.click(at.x, at.y); +}; +const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); }; + +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, valley, 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: 'OpenMike', t: Date.now() }, [valley]: { name: 'NostrValley', about: 'the valley bio here', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, VALLEY, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await toFeed(); + + console.log('\n[a name in the caption]'); + check('the pager opens on the clip', await openClip('Opening night')); + await sleep(500); + let c = await caption(); + check('the caption shows the post\'s words with the name in them', !!c && /Opening night @NostrValley downtown/.test(c.text), JSON.stringify(c)); + check('…and the name is a link', !!c && c.links.includes('@NostrValley'), JSON.stringify(c)); + await clickIn('a', 'middle'); + check('a tap on the name leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('…out of full screen', await page.evaluate(() => !document.fullscreenElement)); + check('…on their profile', await waitText('the valley bio here', 6000)); + + console.log('\n[the caption\'s own words]'); + await toFeed(); + check('the pager opens again', await openClip('Opening night')); + await sleep(500); + await clickIn(null, 'start'); + check('a tap on the words leaves the pager', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('…and opens the post', await waitFor(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a long night of it with music and more'), null, 6000)); + + console.log('\n[a link to the web]'); + await toFeed(); + check('the pager opens on the other clip', await openClip('a tall one')); + await sleep(500); + c = await caption(); + check('its link is a link', !!c && c.links.some((l) => /example\.com/.test(l)), JSON.stringify(c)); + const newTab = new Promise((r) => browser.once('targetcreated', (t) => r(t.url()))); + await clickIn('a[href^="http"]', 'middle'); + const tab = await Promise.race([newTab, sleep(3000).then(() => null)]); + check('a tap on it opens the page in its own tab', !!tab && /example\.com\/about/.test(tab), String(tab)); + check('…and the pager stays open', await page.evaluate(() => !!document.querySelector('.media-pager') && location.pathname !== '/about')); + 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✅ the pager\'s caption: names open profiles, words open the post, web links stay' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js new file mode 100644 index 0000000..6507607 --- /dev/null +++ b/tools/pager-first-frame-test.js @@ -0,0 +1,124 @@ +// The video pager's slides near the reader hold their clip's first frame +// 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 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'; +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 png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +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 FIRST = { wide: jpg('orange', '1280x720'), tall: jpg('green', '720x1280') }; // (last: the server has none) +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); +}; +const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') }; +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 === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } }); + 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 = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK); +const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400)]; + +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); + if (!FIRST[name]) return r.respond({ status: 404, body: 'No picture', headers: { 'access-control-allow-origin': '*' } }); + return r.respond({ status: 200, contentType: 'image/jpeg', body: FIRST[name], headers: { 'access-control-allow-origin': '*' } }); + } + if (u.pathname.startsWith('/vmedia/')) { asked.push('vmedia:' + u.pathname); return r.respond({ status: 404, body: 'no' }); } + if (u.host === 'media.test') { + const name = /\/(\w+)\.webm$/.exec(u.pathname)[1], f = CLIPS[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 kind = (p) => (!p ? 'none' : /\/vmedia\/first\.jpg/.test(p) ? 'server' : /^blob:/.test(p) ? 'drawn' : /still\.png/.test(p) ? 'still' : p); +const slides = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop().replace('.webm', ''), poster: v.getAttribute('poster'), playing: !v.paused, here: Math.abs(r.top) < 4 }; })) + .then((l) => l.map((x) => ({ ...x, poster: kind(x.poster) }))); +const posterOf = async (clip) => (await slides()).find((x) => x.clip === clip)?.poster; +const waitPoster = (clip, k, ms = 8000) => waitFor(([c, k]) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + c + '.webm')); const p = s && s.querySelector('video.mp-media').getAttribute('poster'); return !!p && (k === 'server' ? /\/vmedia\/first\.jpg/.test(p) : k === 'drawn' ? /^blob:/.test(p) : /still\.png/.test(p)); }, [clip, k], ms); + +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)); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); + await sleep(600); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + await page.mouse.click(at.x, at.y); + check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); + check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000)); + + 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(' ')); + 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 ', 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())); + 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✅ the slides near the reader hold their clips\' first frames' : '\n❌ failed'); +process.exit(ok ? 0 : 1); 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-no-poster-test.js b/tools/pager-no-poster-test.js new file mode 100644 index 0000000..0930834 --- /dev/null +++ b/tools/pager-no-poster-test.js @@ -0,0 +1,123 @@ +// A clip that plays by itself in the video pager has no thumbnail poster: +// every poster there was came 480px wide and from some moment of the +// clip, so it showed soft for a beat and the clip's first frame replaced +// it — a flash on every flick. What the clip ahead holds instead is its +// own first frame, full size, drawn from the opening of the file: the +// flick glides onto the very picture the player shows first. On a +// connection to be careful with (Data Saver), where a clip may never load, +// the thumbnail poster stays. +// Run: bun tools/pager-no-poster-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 png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', '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 f; +}; +const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') }; +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; + const m = /^\/(wide|tall|last)\.webm$/.exec(path); + if (m) { + // a host that serves ranges, as the real ones do + const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || ''); + if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } }); + const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1; + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } }); + } + if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } }); + 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) => server.url.origin + '/' + n + '.webm'; +// each clip's post names a poster for it (imeta), the way uploaders do +const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK); +const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400), + ...Array.from({ length: 6 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 600 - i * 60, tags: [], content: 'filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4) }, SK))]; + +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.evaluateOnNewDocument(() => { + window.__conn = { saveData: localStorage.getItem('__saveData') === '1' }; // (survives the reloads) + Object.defineProperty(navigator, 'connection', { value: window.__conn, configurable: true }); + 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 slides = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const v = s.querySelector('video.mp-media'); const r = s.getBoundingClientRect(); return { clip: s.getAttribute('data-k').split('/').pop(), poster: v.getAttribute('poster'), src: !!v.getAttribute('src'), playing: !v.paused, here: Math.abs(r.top) < 4 }; })); +const openClip = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); + await sleep(400); + const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); +}; + +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]); + const open = async () => { + 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)); + await openClip('a wide one'); + check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); + await waitFor(() => [...document.querySelectorAll('.mp-slide video.mp-media')].some((v) => !v.paused), null, 6000); + await sleep(600); + return slides(); + }; + + console.log('\n[playing by itself]'); + let s = await open(); + check('the clip on screen plays', s.some((x) => x.here && x.playing), JSON.stringify(s)); + check('…with no poster on it', s.filter((x) => x.here).every((x) => !x.poster), JSON.stringify(s.filter((x) => x.here))); + check('…no thumbnail on the clips ahead either', s.filter((x) => !x.here).every((x) => !/still\.png/.test(x.poster || '')), JSON.stringify(s.filter((x) => !x.here))); + check('the next clip holds its own first frame', await waitFor(() => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm'))?.querySelector('video.mp-media'); return !!v && /^blob:/.test(v.getAttribute('poster') || ''); }, null, 8000), JSON.stringify(await slides())); + const frame = await page.evaluate(() => new Promise((r) => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')).querySelector('video.mp-media'); const im = new Image(); im.onload = () => r({ w: im.naturalWidth, h: im.naturalHeight }); im.onerror = () => r(null); im.src = v.getAttribute('poster'); })); + check('…full size, the clip\'s own shape', !!frame && frame.w === 180 && frame.h === 320, JSON.stringify(frame)); + check('…and the clip after it, not yet near, holds nothing', s.some((x) => x.clip === 'last.webm' && !x.poster), JSON.stringify(s)); + await page.keyboard.press('ArrowDown'); await sleep(900); + s = await slides(); + check('the next clip plays, its first frame under it', s.some((x) => x.here && x.playing && /^blob:/.test(x.poster || '') && x.clip === 'tall.webm'), JSON.stringify(s)); + check('…and the one after now holds its first frame', await waitFor(() => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/last.webm'))?.querySelector('video.mp-media'); return !!v && /^blob:/.test(v.getAttribute('poster') || ''); }, null, 8000)); + await page.keyboard.press('Escape'); await sleep(500); + + console.log('\n[a careful connection: the poster stays]'); + await page.evaluate(() => { localStorage.setItem('__saveData', '1'); }); + s = await open(); + check('the slides carry their posters', s.length >= 3 && s.every((x) => /still\.png$/.test(x.poster || '')), JSON.stringify(s)); + 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✅ an autoplaying clip starts on its own first frame, the one ahead holds it already' : '\n❌ failed'); +process.exit(ok ? 0 : 1); 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 new file mode 100644 index 0000000..64ff7b5 --- /dev/null +++ b/tools/pager-seek-hide-test.js @@ -0,0 +1,129 @@ +// The video pager's seek bar shows only on the clip square on the screen: +// 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. 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'; +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 png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', '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 f; +}; +const CLIPS = { wide: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') }; +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; + const m = /^\/(wide|tall|last)\.webm$/.exec(path); + if (m) { + const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || ''); + if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } }); + const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1; + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } }); + } + if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } }); + 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) => server.url.origin + '/' + n + '.webm'; +const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK); +const seeded = [post('a wide one ' + U('wide'), U('wide'), 100), post('a tall one ' + U('tall'), U('tall'), 200), post('the last clip ' + U('last'), U('last'), 400)]; + +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.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; }; +// each slide's bar: its clip, whether it is the one on screen, whether the bar can be seen +const bars = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const b = s.querySelector('.mp-seek'), cs = getComputedStyle(b); return { clip: s.getAttribute('data-k').split('/').pop(), cur: s.classList.contains('mp-cur'), seen: cs.visibility === 'visible' && +cs.opacity > 0.01, opacity: cs.opacity, ready: b.classList.contains('ready') }; })); +const seenBars = async () => (await bars()).filter((x) => x.seen).map((x) => x.clip + '@' + x.opacity); +// 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 }); + 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)); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); + await sleep(400); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + await page.mouse.click(at.x, at.y); + check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); + + console.log('\n[at rest: the clip on screen has its seek bar]'); + check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000)); + 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(); + await page.keyboard.press('ArrowDown'); + await sleep(1600); + const tr = await trace(); + const between = tr.filter((f) => f.top > 2 && Math.abs(f.top - 844) > 2 && f.top < 844); + check('the glide was seen frame by frame', between.length >= 3, between.length + ' frames between the two clips'); + check('…every one of them with the pager marked moving', between.every((f) => f.moving), between.map((f) => (f.moving ? 'm' : '-')).join('')); + check('…and not one seek bar to be seen', between.every((f) => f.seen === 0), between.map((f) => f.seen).join('')); + const landedOn = await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000); + check('it lands on the next clip', landedOn); + check('…whose seek bar fades back in', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars())); + check('…the one before, now off screen, shows none', (await bars()).filter((x) => x.clip === 'wide.webm').every((x) => !x.seen), JSON.stringify(await bars())); + check('the pager is not marked moving once landed', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving'))); + + console.log('\n[a finger holding the slides off their mark: none either]'); + await page.touchscreen.touchStart(195, 600); + await page.touchscreen.touchMove(195, 560); await sleep(30); + 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'); +process.exit(ok ? 0 : 1); 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/registry-ownership-test.js b/tools/registry-ownership-test.js deleted file mode 100644 index 43683a1..0000000 --- a/tools/registry-ownership-test.js +++ /dev/null @@ -1,18 +0,0 @@ -import assert from 'node:assert/strict'; -import { registrationIdentity } from '../names/ownership.js'; - -const owner = 'a'.repeat(64), wallet = 'b'.repeat(64), stranger = 'c'.repeat(64); -const grant = { pubkey: owner, manager: wallet }; -for (const signer of [owner, wallet]) { - assert.deepEqual(registrationIdentity({ signer, grant, granted: true }), { pubkey: owner, manager: wallet }); -} -console.log('✓ owner and manager registrations both preserve the migration identity'); -assert.deepEqual(registrationIdentity({ signer: wallet, manager: wallet, grant, granted: true }), { pubkey: owner, manager: wallet }); -console.log('✓ the signing wallet can remain the nominated manager'); -const existing = { pubkey: owner, manager: wallet }; -assert.deepEqual(registrationIdentity({ existing, signer: wallet, grant: { pubkey: stranger }, granted: false }), existing); -console.log('✓ later wallet updates preserve existing ownership and management'); -assert.deepEqual(registrationIdentity({ signer: wallet, grant, granted: false }), { pubkey: wallet, manager: undefined }); -assert.deepEqual(registrationIdentity({ signer: owner, manager: wallet }), existing); -assert.deepEqual(registrationIdentity({ signer: owner, manager: owner }), { pubkey: owner, manager: undefined }); -console.log('✓ ordinary claims retain their signer and ignore unauthorized grants'); diff --git a/tools/sync-blob-test.js b/tools/sync-blob-test.js index ee1c60a..e8e25cb 100644 --- a/tools/sync-blob-test.js +++ b/tools/sync-blob-test.js @@ -1,6 +1,6 @@ // Oversized sync domains on Blossom: the envelope, the server's policy, the // outbox's upload-then-publish flow, and a fresh device resolving a pointer. -// Spawns blossom/server.js on a free port. Run: bun tools/sync-blob-test.js +// Spawns coinosv3-server's blossom/server.js on a free port. Run: bun tools/sync-blob-test.js import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -30,7 +30,13 @@ const a = signer(), b = signer('someone else'); // ---- the server ------------------------------------------------------------ const port = 20000 + Math.floor(Math.random() * 10000); const dataDir = mkdtempSync(join(tmpdir(), 'blossom-test-')); -const server = Bun.spawn(['bun', new URL('../blossom/server.js', import.meta.url).pathname], { +// The Blossom server lives in the coinosv3-server repo (a sibling checkout, +// or COINOSV3_SERVER); this test is the wallet's side of the sync. +import { existsSync } from 'node:fs'; +const serverRepo = process.env.COINOSV3_SERVER || join(process.env.HOME, 'coinosv3-server'); +const blossomJs = join(serverRepo, 'blossom', 'server.js'); +if (!existsSync(blossomJs)) { console.log(`skipped: no blossom server at ${blossomJs} (clone coinosv3-server beside this repo, or set COINOSV3_SERVER)`); process.exit(0); } +const server = Bun.spawn(['bun', blossomJs], { env: { ...process.env, BLOSSOM_DATA: dataDir, BLOSSOM_PORT: String(port), BLOSSOM_HOST: 'localhost', BLOSSOM_QUOTA_COUNT: '2', BLOSSOM_MAX_BLOB: String(200_000) }, stdout: 'ignore', stderr: 'inherit', }); diff --git a/tools/thread-instant-test.js b/tools/thread-instant-test.js new file mode 100644 index 0000000..bd80408 --- /dev/null +++ b/tools/thread-instant-test.js @@ -0,0 +1,158 @@ +// A thread opens whole, at once, for a post that has been on screen: the +// feed already fetches every post's replies for the count, and the thread +// is seeded from them — the relays only add to it. A thread with nothing in +// hand paints its replies as they arrive, not after the slowest relay. +// Run: bun tools/thread-instant-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 SKA = generateSecretKey(), ALICE = getPublicKey(SKA), SKB = generateSecretKey(), BOB = getPublicKey(SKB), SKC = generateSecretKey(), CAROL = getPublicKey(SKC); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk); +const A1 = post('alice asks the room a question', 900, SKA); +const B1 = post('bob posts something quieter', 800, SKB); +const R1 = post('bob answers alice first', 700, SKB, [['e', A1.id, '', 'root'], ['p', ALICE]]); +const R2 = post('carol answers alice too', 600, SKC, [['e', A1.id, '', 'root'], ['p', ALICE]]); +const R3 = post('alice answers bob back', 500, SKA, [['e', A1.id, '', 'root'], ['e', R1.id, '', 'reply'], ['p', BOB]]); +const S1 = post('carol on bob, quick relay', 400, SKC, [['e', B1.id, '', 'root'], ['p', BOB]]); +const S2 = post('alice on bob, slow relay', 300, SKA, [['e', B1.id, '', 'root'], ['p', BOB]]); +const C1 = post('carol posts last, fetched ahead', 200, SKC); +const U1 = post('bob on carol, held back', 100, SKB, [['e', C1.id, '', 'root'], ['p', CAROL]]); +const html = await buildHtml({ minify: true, pwa: false }); +const FACE = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=96x96', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +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)); + if (path === '/bob.png') return new Response(FACE, { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +// The relays, answered in the page. The feed's count fetch (one REQ, no +// limit, every post on screen) is held until the test releases it: then +// alice's three replies and carol's one land, bob's none. A thread's own +// ask (a limit): alice's answered after 3 s, carol's after 6 s, bob's one +// reply at 300 ms and the other at 2.5 s, EOSE after. +await page.evaluateOnNewDocument((fx) => { + window.__reqs = []; + // the thread prefetch (on at first) is switched off (data saver) once the + // part of the test that wants it is done + window.__conn = { saveData: false }; + Object.defineProperty(navigator, 'connection', { value: window.__conn, configurable: true }); + window.__held = []; + window.__releaseC = () => { for (const f of window.__held) f(); window.__held = []; }; + const Real = window.WebSocket; + const send1 = (ws, id, ev) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); }; + const eose = (ws, id) => { if (ws.readyState === 1) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); }; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const fs = m.slice(2); + window.__reqs.push({ t: Date.now(), fs }); + const on = (id) => fs.find((f) => (f['#e'] || []).includes(id) && (f.kinds || []).includes(1)); + const onA = on(fx.A1), onB = on(fx.B1), onC = on(fx.C1); + const count = [onA, onB, onC].find((f) => f && !f.limit); + if (count) { window.__held.push(() => { for (const ev of [...fx.aReplies, ...fx.cReplies]) send1(ws, m[1], ev); eose(ws, m[1]); }); return; } + if (onC && onC.limit) { setTimeout(() => { send1(ws, m[1], fx.cReplies[0]); eose(ws, m[1]); }, 6000); return; } + if (onA && onA.limit) { setTimeout(() => { for (const ev of fx.aReplies) send1(ws, m[1], ev); eose(ws, m[1]); }, 3000); return; } + if (onB && onB.limit) { setTimeout(() => send1(ws, m[1], fx.bReplies[0]), 300); setTimeout(() => { send1(ws, m[1], fx.bReplies[1]); eose(ws, m[1]); }, 2500); return; } + } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); +}, { A1: A1.id, B1: B1.id, C1: C1.id, aReplies: [R1, R2, R3], bReplies: [S1, S2], cReplies: [U1] }); +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, step = 100) => { for (let i = 0; i < ms / step; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(step); } return false; }; +const thread = () => page.evaluate(() => ({ + rows: [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')), + spinner: !!document.querySelector('.thread-card .spinner'), + text: document.querySelector('.thread-card')?.innerText || '', + face: [...document.querySelectorAll('.thread-page [data-zap-post]')].slice(-1)[0]?.querySelector('.note-avatar')?.style.backgroundImage || '', +})); +const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + 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, alice, bob, carol, notes]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', alice], ['p', bob]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(notes)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [alice]: { name: 'Alice Asker', t: Date.now() }, [bob]: { name: 'Bob Quiet', picture: location.origin + '/bob.png', t: Date.now() }, [carol]: { name: 'Carol Quick', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, ALICE, BOB, CAROL, [C1, B1, A1]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the feed paints the posts', await waitFor(() => document.querySelectorAll('.notes-feed .note-post[data-zap-post]').length >= 3, null, 20000)); + console.log('\n[fetched ahead before the count answered]'); + check('reading carol\'s post for a moment fetches its thread ahead', await waitFor((id) => window.__reqs.some((r) => r.fs.some((f) => (f['#e'] || []).includes(id) && f.limit)), C1.id, 15000)); + await page.evaluate(() => { window.__conn.saveData = true; }); // no more fetching ahead: the later parts want their threads untouched + await sleep(300); + await page.evaluate(() => window.__releaseC()); // now the count answer lands, after the thread entry was made + check('…and the count answer brings its reply', await waitFor((id) => /\b1\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), C1.id, 8000)); + const t2 = Date.now(); + await openRow(C1.id); + let ahead = null; + for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.includes(U1.id)) { ahead = { ...th, ms: Date.now() - t2 }; break; } await sleep(25); } + check('the thread opens with the reply from the count, not after its own 6 s ask', !!ahead && ahead.ms < 1500 && !ahead.spinner, ahead ? ahead.ms + ' ms' : 'never'); + check('…painted in its final shape: bob\'s face was fetched ahead, on the row at its first paint', !!ahead && /blob:|data:image/.test(ahead.face || ''), ahead ? String(ahead.face).slice(0, 60) : ''); + await page.goBack(); await sleep(800); + await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000); + + check('the count fetch brought alice\'s replies', await waitFor((id) => /\b3\b/.test(document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"]')?.innerText || ''), A1.id, 15000), 'reply count on the row'); + const asked = await page.evaluate(() => window.__reqs.length); + + console.log('\n[a post that has been on screen]'); + const t0 = Date.now(); + await openRow(A1.id); + let first = null; + for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows.length >= 4) { first = { ...th, ms: Date.now() - t0 }; break; } await sleep(25); } + check('the thread opens whole, at once — the replies in hand, no spinner', !!first && first.ms < 1500 && first.rows[0] === A1.id && [R1.id, R2.id, R3.id].every((id) => first.rows.includes(id)) && !first.spinner, first ? first.ms + ' ms, ' + first.rows.length + ' rows, spinner ' + first.spinner : 'never'); + check('…before the relays answered the thread\'s own ask', (Date.now() - t0) < 2500); + await sleep(3500); + const later = await thread(); + check('the relays\' answer adds nothing twice; no spinner, no "no replies"', later.rows.length === 4 && !later.spinner && !/no replies/i.test(later.text), later.rows.length + ' rows'); + await page.goBack(); await sleep(800); + await waitFor(() => !!document.querySelector('.feed-chips'), null, 6000); + + console.log('\n[a post with nothing in hand]'); + const t1 = Date.now(); + await openRow(B1.id); + let opened = null; + for (let i = 0; i < 40; i++) { const th = await thread(); if (th.rows[0] === B1.id) { opened = { ...th, ms: Date.now() - t1 }; break; } await sleep(20); } + check('the post reads at once: no spinner, no "no replies" while they are on their way', !!opened && !opened.spinner && !/no replies/i.test(opened.text), opened ? opened.ms + ' ms' : 'never'); + let streamed = null, spun = false; + for (let i = 0; i < 80; i++) { const th = await thread(); spun ||= th.spinner; if (th.rows.includes(S1.id)) { streamed = { ...th, ms: Date.now() - t1 }; break; } await sleep(25); } + check('the first reply pops in as it arrives, the slow one still on its way', !!streamed && streamed.ms < 2500 && !streamed.rows.includes(S2.id), streamed ? streamed.ms + ' ms' : 'never'); + check('…never a spinner', !spun && !!streamed && !streamed.spinner); + check('the slow one lands in its place', await waitFor((id) => { const rows = [...document.querySelectorAll('.thread-page [data-zap-post]')].map((r) => r.getAttribute('data-zap-post')); return rows.length === 3 && rows[2] === id && !document.querySelector('.thread-card .spinner'); }, S2.id, 6000)); + 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✅ threads open at once from the replies in hand, and fill in as they arrive' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/thread-prefetch-test.js b/tools/thread-prefetch-test.js index 6329e64..22107c6 100644 --- a/tools/thread-prefetch-test.js +++ b/tools/thread-prefetch-test.js @@ -15,7 +15,7 @@ const row = (id, top = 0, height = 300) => ({ id, top, height, }); const tick = async (ms = 250) => { time += ms; const fn = task; task = null; fn?.(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }; const watch = (...rs) => { rows = rs; for (const r of rs) p.watch({ id: r.id }); }; -const dwell = async () => { for (let i = 0; i < 7; i++) await tick(); }; +const dwell = async () => { for (let i = 0; i < 4; i++) await tick(); }; // a second: past the 600 ms watch(row('passing')); await tick(); rows = []; await dwell(); assert.deepEqual(calls, []); watch(row('moving')); for (let i = 0; i < 8; i++) { rows[0].top += 30; await tick(); } assert.deepEqual(calls, []); watch(row('hidden')); doc.hidden = true; await dwell(); assert.deepEqual(calls, []); doc.hidden = false; @@ -24,8 +24,8 @@ watch(row('tall', -200, 1800), row('second', 100)); await dwell(); assert.deepEq await dwell(); assert.deepEqual(calls, ['tall'], 'only one in-flight fetch'); resolve(); await tick(); await tick(); assert.deepEqual(calls, ['tall', 'second']); resolve(); await tick(); await dwell(); assert.equal(calls.length, 2, 'cached posts are reused'); -for (let i = 0; i < 5; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); } -assert.equal(calls.length, 6, 'six starts per minute'); -await tick(60000); assert.equal(calls.length, 7); resolve(); await tick(); -watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 7); +for (let i = 0; i < 11; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); } +assert.equal(calls.length, 12, 'twelve starts per minute'); +await tick(60000); assert.equal(calls.length, 13); resolve(); await tick(); +watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 13); console.log('✓ dwell, fast scrolling, unmount, hidden tab, data saver, tall posts, concurrency, cache, rate limit and stop'); 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); |
