diff options
Diffstat (limited to 'tools')
38 files changed, 3506 insertions, 98 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/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..fe62413 100644 --- a/tools/feed-media-views-test.js +++ b/tools/feed-media-views-test.js @@ -126,8 +126,13 @@ 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 mode('images'); diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js index 32625c9..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); @@ -82,12 +83,14 @@ try { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); - console.log('\n[a tap on the picture that opened it closes it]'); + console.log('\n[a tap on the picture that opened it shows its details; the next closes it]'); const y0 = await openPic('two pictures'); check('the picture opens in the pager, on itself', await resting('a1.png'), await curPic()); check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show'))); + await tapPager(); await sleep(400); + check('one tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); await tapPager(); await sleep(600); - check('one tap closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); check('the feed is where it was', Math.abs((await page.evaluate(() => window.scrollY)) - y0) <= 2); console.log('\n[left and right: along a post\'s pictures; up and down: post to post]'); diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js index 8a00b11..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,37 +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 hunt: a quarter of a notch's travel, not a flight the other way. - const back = (before - f.y) * dir; - assert(back > 65 && back < 90, 'a reverse notch against a coast is a short step: ' + back); - // Each notch on grows the step back towards a full one; the sixth is a plain notch again. - const steps = []; - for (let i = 0; i < 5; i++) { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); steps.push((y - f.y) * dir); } - assert(steps.every((s, i) => i === 0 || s > steps[i - 1] * 1.08), 'hunting steps grow notch by notch: ' + steps.map(Math.round)); - assert(steps[0] > 95 && steps[0] < 125 && steps[3] > 240 && steps[3] < 285 && steps[4] > 280 && steps[4] < 330, 'from about a third of a notch to a full one: ' + steps.map(Math.round)); - { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a few flicks on, the wheel flies again: ' + s); } - // A turn back while hunting starts the small steps over. - f.wheel(-dir); f.advance(17); f.frame(); f.advance(17); f.frame(); - { const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 65 && s < 90, 'a reversal while hunting is a short step again: ' + s); } - { f.advance(300); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 95 && s < 120, 'and the next grows from there: ' + s); } - // A pause ends the hunt: the next notch is the plain push from rest. - { f.advance(2200); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'after a pause a notch is a full push: ' + s); } - // A turn back against a coast that only just died hunts too. - { f.wheel(dir); settle(f); f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 65 && s < 90, 'reversing right after the coast dies is a short step: ' + s); } - // ...but a notch from rest the other way, well after, is a full one. + 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 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, precision hunt after a reversal (short steps that grow, restart on another turn back, end on a pause)'); +{ 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/lnurl-auth-test.js b/tools/lnurl-auth-test.js new file mode 100644 index 0000000..767e85c --- /dev/null +++ b/tools/lnurl-auth-test.js @@ -0,0 +1,102 @@ +// A site's login lnurl (LNURL-auth) pasted into Send is a login, not a +// payment: the wallet asks first, naming the site, then signs the challenge +// and tells the site; the site's refusal is shown as its own words. Against +// a stand-in login server that checks the signature the way a real one does. +// Run: bun tools/lnurl-auth-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { hexToBytes } from '@noble/hashes/utils'; +import { encodeLnurl } from '../src/lnurl.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 logins = []; // what the stand-in site was told +const server = Bun.serve({ port: 0, fetch: (req) => { + const u = new URL(req.url), p = u.pathname; + if (p === '/lnauth') { + const sig = u.searchParams.get('sig'), key = u.searchParams.get('key'), k1 = u.searchParams.get('k1'); + if (!sig) return Response.json({ status: 'ERROR', reason: 'no sig query variable provided' }); + let good = false; + try { good = secp256k1.verify(secp256k1.Signature.fromDER(sig), hexToBytes(k1), hexToBytes(key)); } catch {} + logins.push({ k1, key, good }); + return Response.json(good ? { status: 'OK' } : { status: 'ERROR', reason: 'bad signature' }); + } + if (p === '/lnauth-closed') return Response.json({ status: 'ERROR', reason: 'registrations are closed' }); + if (p.startsWith('/punks') || p === '/verify-worker.js' || p.startsWith('/locales') || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { status: 404 }); } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const k1 = 'c0ffee'.repeat(10) + 'abcd'; +const loginLnurl = encodeLnurl(server.url.origin + '/lnauth?tag=login&k1=' + k1 + '&action=login'); +const closedLnurl = encodeLnurl(server.url.origin + '/lnauth-closed?tag=login&k1=' + k1 + '&action=register'); +const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const ctx = browser.defaultBrowserContext(); +await ctx.overridePermissions(server.url.origin, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); +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() === x || 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 = 8000) => { for (let i = 0; i < ms / 200; i++) { if ((await text()).includes(x)) return true; await sleep(200); } return false; }; +const freshSend = async () => { + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Receive')?.click()); await sleep(300); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Send')?.click()); + await page.waitForSelector('textarea.send-dest', { timeout: 10000 }); + await page.$eval('textarea.send-dest', (e) => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await sleep(300); + await page.focus('textarea.send-dest'); +}; +const paste = async (s) => { await page.evaluate((v) => navigator.clipboard.writeText(v), s); await page.keyboard.down('Control'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400); + await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet'); + for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) { + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /^(not now|skip|continue|later)$/i.test(b.textContent.trim()))?.click()); + await sleep(400); + } + await sleep(3000); + const host = new URL(server.url.origin).hostname; + + console.log('[a login lnurl pasted into Send]'); + await freshSend(); + await paste(loginLnurl); + check('the wallet asks first, naming the site', await waitText('Log in with Lightning') && (await text()).includes(host + ' is asking you to log in'), (await text()).slice(0, 200)); + check('…says what it is about to do, and that nothing is paid', /key it keeps for this site alone/.test(await text()) && /No payment is made/.test(await text())); + check('…no amount field, no payment flow', !(await page.$('input[type=number]'))); + check('nothing was sent to the site before the user agreed', logins.length === 0); + await click('log in'); + check('the site accepts the signed challenge', await waitText('Logged in to ' + host, 8000), (await text()).slice(0, 300)); + check('…it was told once, with a signature that verifies under the key', logins.length === 1 && logins[0].good && logins[0].k1 === k1, JSON.stringify(logins)); + const key1 = logins[0].key; + await click('done'); + check('Done returns to a blank Send form', await page.waitForSelector('textarea.send-dest', { timeout: 5000 }).then(() => true, () => false) && (await page.$eval('textarea.send-dest', (e) => e.value)) === ''); + + console.log('[the same site again: the same key]'); + await freshSend(); + await paste(loginLnurl); + await waitText('Log in with Lightning'); await click('log in'); + await waitText('Logged in to ' + host, 8000); + check('the wallet is the same user on the site next time', logins.length === 2 && logins[1].key === key1, JSON.stringify(logins.map((l) => l.key.slice(0, 12)))); + await click('done'); + + console.log('[a site that says no]'); + await freshSend(); + await paste(closedLnurl); + check('a register link asks to create an account', await waitText('asking you to create an account')); + await click('create account'); + check("the site's reason is shown", await waitText('registrations are closed', 8000), (await text()).slice(0, 300)); + check('…with a way to try again, and back', (await text()).includes('Try again') && (await text()).includes('Back')); + await click('back'); + check('Back leaves the login', await page.waitForSelector('textarea.send-dest', { timeout: 5000 }).then(() => true, () => false)); + check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300)); +} catch (e) { + check('run completed', false, e.stack); +} finally { + await browser.close(); server.stop(true); +} +console.log(ok ? '\n✅ a login lnurl logs in' : '\n❌ failures above'); +process.exit(ok ? 0 : 1); diff --git a/tools/lnurl-auth-unit-test.js b/tools/lnurl-auth-unit-test.js new file mode 100644 index 0000000..c9989be --- /dev/null +++ b/tools/lnurl-auth-unit-test.js @@ -0,0 +1,63 @@ +// LNURL-auth (LUD-04/05) without a browser: a login lnurl is told from a +// pay one; the linking key is the same for a domain every time and different +// per domain; the signature over k1 verifies under the key sent. +// Run: bun tools/lnurl-auth-unit-test.js +import assert from 'node:assert/strict'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { encodeLnurl, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin } from '../src/lnurl.js'; +const k1 = 'a'.repeat(64); +const loginUrl = 'https://stacker.news/api/lnauth?tag=login&k1=' + k1 + '&action=login'; +const a = parseLnurlAuth(encodeLnurl(loginUrl)); +assert.deepEqual(a, { url: loginUrl, domain: 'stacker.news', k1, action: 'login' }); +assert.equal(parseLnurlAuth('lightning:' + encodeLnurl(loginUrl).toUpperCase()).domain, 'stacker.news', 'a lightning: prefix and upper case are fine'); +assert.equal(parseLnurlAuth(encodeLnurl('https://stacker.news/api/lnauth?tag=login&k1=zz')), null, 'a k1 that is not 32 bytes is not a login'); +assert.equal(parseLnurlAuth(encodeLnurl('https://coinos.io/.well-known/lnurlp/adam')), null, 'a pay lnurl is not a login'); +assert.equal(parseLnurlAuth('[email protected]'), null); +assert.equal(parseLnurlAuth(encodeLnurl(loginUrl.replace('action=login', 'action=register'))).action, 'register'); +assert.equal(parseLnurlAuth(encodeLnurl(loginUrl.replace('action=login', 'action=what'))).action, 'login', 'an unknown action is a login'); +const master = HDKey.fromMasterSeed(mnemonicToSeedSync('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about')); +const k = lnurlAuthKey(master, 'stacker.news'); +assert.equal(bytesToHex(k.publicKey), bytesToHex(lnurlAuthKey(master, 'stacker.news').publicKey), 'the same key for a domain every time'); +assert.notEqual(bytesToHex(k.publicKey), bytesToHex(lnurlAuthKey(master, 'coinos.io').publicKey), 'another domain, another key'); +assert.equal(k.publicKey.length, 33, 'a compressed public key'); +assert.notEqual(bytesToHex(lnurlAuthKey(HDKey.fromMasterSeed(mnemonicToSeedSync('zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong')), 'stacker.news').publicKey), bytesToHex(k.publicKey), 'another wallet, another user'); +// the callback, with a server that checks the signature the way theirs does +let got = null; +globalThis.fetch = async (url) => { + const u = new URL(url); + got = { sig: u.searchParams.get('sig'), key: u.searchParams.get('key'), k1: u.searchParams.get('k1'), tag: u.searchParams.get('tag') }; + const okSig = secp256k1.verify(secp256k1.Signature.fromDER(got.sig), hexToBytes(got.k1), hexToBytes(got.key)); + return new Response(JSON.stringify(okSig ? { status: 'OK' } : { status: 'ERROR', reason: 'bad sig' }), { headers: { 'content-type': 'application/json' } }); +}; +const r = await lnurlAuthLogin(a, k); +assert.equal(got.tag, 'login'); assert.equal(got.k1, k1); +assert.equal(got.key, bytesToHex(k.publicKey), 'the key sent is the linking key'); +assert.equal(r.key, got.key); +assert.match(got.sig, /^30[0-9a-f]+$/, 'a DER signature'); +globalThis.fetch = async () => new Response(JSON.stringify({ status: 'ERROR', reason: 'no such user' }), { headers: { 'content-type': 'application/json' } }); +await assert.rejects(lnurlAuthLogin(a, k), /no such user/, "the server's reason is the error"); +console.log('✓ lnurl-auth: a login is told from a payment, one key per site per wallet, the signature verifies, the server has the last word'); + +// ---- LNURL-withdraw (LUD-03) ---- +const { parseZapTarget, withdrawParamsOf, fetchWithdrawParams, submitWithdraw, fetchPayParams } = await import('../src/lnurl.js'); +const w = parseZapTarget('lnurlw://pay.example.com/withdraw?k=1'); +assert.equal(w.url, 'https://pay.example.com/withdraw?k=1', 'lnurlw:// is the URL over https'); +assert.equal(parseZapTarget('LIGHTNING:lnurlp://pay.example.com/.well-known/lnurlp/bob').url, 'https://pay.example.com/.well-known/lnurlp/bob'); +assert.equal(parseZapTarget('lnurlw://abc.onion/w').url, 'http://abc.onion/w', 'an onion over http'); +const terms = { tag: 'withdrawRequest', callback: 'https://pay.example.com/cb', k1: 'k1k1', minWithdrawable: 1000, maxWithdrawable: 50000, defaultDescription: 'faucet' }; +assert.deepEqual(withdrawParamsOf(terms), { callback: 'https://pay.example.com/cb', k1: 'k1k1', minMsat: 1000, maxMsat: 50000, description: 'faucet', domain: 'pay.example.com' }); +assert.equal(withdrawParamsOf({ tag: 'payRequest', callback: 'x' }), null); +assert.equal(withdrawParamsOf({ ...terms, maxWithdrawable: 0 }), null, 'nothing to withdraw is no withdraw'); +globalThis.fetch = async () => Response.json(terms); +assert.deepEqual(await fetchWithdrawParams('https://pay.example.com/w'), withdrawParamsOf(terms)); +await assert.rejects(fetchPayParams('https://pay.example.com/w'), (e) => e.withdraw && e.withdraw.k1 === 'k1k1', 'a pay fetch that finds a withdraw says so'); +let cb = null; +globalThis.fetch = async (url) => { const u = new URL(url); cb = { path: u.pathname, k1: u.searchParams.get('k1'), pr: u.searchParams.get('pr') }; return Response.json({ status: 'OK' }); }; +assert.equal(await submitWithdraw(withdrawParamsOf(terms), 'lnbc1invoice'), true); +assert.deepEqual(cb, { path: '/cb', k1: 'k1k1', pr: 'lnbc1invoice' }, 'the callback gets k1 and the invoice'); +globalThis.fetch = async () => Response.json({ status: 'ERROR', reason: 'already claimed' }); +await assert.rejects(submitWithdraw(withdrawParamsOf(terms), 'lnbc1invoice'), /already claimed/); +console.log('✓ lnurl-withdraw: lnurlw:// and lnurlp:// schemes, the terms, a pay fetch that finds a withdraw, the callback, the refusal'); diff --git a/tools/lnurl-withdraw-test.js b/tools/lnurl-withdraw-test.js new file mode 100644 index 0000000..cc1b152 --- /dev/null +++ b/tools/lnurl-withdraw-test.js @@ -0,0 +1,103 @@ +// LNURL-withdraw: a site's withdraw lnurl pasted into Send is an offer to +// PAY us — the wallet shows their range, makes an invoice for the amount +// chosen, hands it to their callback and watches for the money. The Ark +// side is faked (the invoice hook); the site is a stand-in on the test +// server. Run: bun tools/lnurl-withdraw-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { encodeLnurl } from '../src/lnurl.js'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { zapsFeature } from './src/features/zaps.js'; +${domHelper} +const ui = { screen: 'wallet', tab: 'send', send: { recipients: [{ address: '', amount: '' }] } }; +let watch = null, cancelled = [], invoices = 0; +const wallet = { watchOnly: false, loadFeatureState: (k, d) => d, saveFeatureState: () => {} }; +const ctx = { + h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m), + blankSend: () => ({ recipients: [{ address: '', amount: '' }] }), + fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'), + parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) ? Math.round(n) : null; }, getUnit: () => 'sats', + amountField: (sat) => String(sat), + hook(name, ...a) { + if (name === 'canLnPay') return true; + if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); } + if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; } + if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; } + if (name === 'arkLnCancel') { cancelled.push(a[0]); return Promise.resolve(true); } + return null; + }, +}; +const feature = zapsFeature(ctx); window.__feature = feature; +function render() { document.querySelector('#app').replaceChildren(feature.sendView() || h('div', { id: 'closed' }, 'closed')); } +window.test = { paste: (s) => feature.matchSendText(s, false), settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, ui }; +render(); +`; +const bundle = await Bun.build({ + entrypoints: ['lnw-test-entry'], target: 'browser', + plugins: [{ name: 'lnw-test', setup(build) { + build.onResolve({ filter: /^lnw-test-entry$/ }, () => ({ path: 'entry', namespace: 'lnw-test' })); + build.onLoad({ filter: /.*/, namespace: 'lnw-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const css = await Bun.file('src/style.css').text(); +const js = await bundle.outputs[0].text(); +const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`; +const claims = []; +const server = Bun.serve({ port: 0, fetch: (req) => { + const u = new URL(req.url); + if (u.pathname === '/withdraw') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb', k1: 'deadbeef', minWithdrawable: 100000, maxWithdrawable: 5000000, defaultDescription: 'Faucet drip' }); + if (u.pathname === '/withdraw-fixed') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb', k1: 'fixed1', minWithdrawable: 21000, maxWithdrawable: 21000 }); + if (u.pathname === '/withdraw-spent') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb-spent', k1: 'spent', minWithdrawable: 1000, maxWithdrawable: 1000 }); + if (u.pathname === '/cb') { claims.push({ k1: u.searchParams.get('k1'), pr: u.searchParams.get('pr') }); return Response.json({ status: 'OK' }); } + if (u.pathname === '/cb-spent') return Response.json({ status: 'ERROR', reason: 'this voucher was already used' }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const lnurl = (path) => encodeLnurl(server.url.origin + path); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); +const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +const waitText = async (re, ms = 5000) => { for (let i = 0; i < ms / 100; i++) { if (re.test(await text())) return true; await pause(100); } return false; }; +const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => n.textContent.trim() === x || n.textContent.trim().endsWith(' ' + x)); if (!b) return false; b.click(); return true; }, label); +const type = (sel, v) => page.evaluate(([s, val]) => { const i = document.querySelector(s); i.value = val; i.dispatchEvent(new Event('input', { bubbles: true })); }, [sel, v]); +const host = new URL(server.url.origin).hostname; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href); + await page.waitForSelector('#closed'); + console.log('[a withdraw lnurl with a range]'); + check('the paste is claimed', await page.evaluate((s) => test.paste(s), lnurl('/withdraw'))); + check('their terms show: the site, the range, the description', await waitText(/Withdraw over Lightning/) && new RegExp(host + ' will pay you between 100 and 5,000 sats').test(await text()) && /Faucet drip/.test(await text()), await text()); + check('the amount starts at their max', await page.$eval('input[type=number]', (i) => i.value) === '5000'); + await type('input[type=number]', '9000'); await click('Withdraw'); await pause(100); + check('over their max: told the range, nothing sent', /Between 100 and 5,000 sats/.test(await text()) && claims.length === 0); + await type('input[type=number]', '1500'); await click('Withdraw'); + check('an invoice for the amount is handed to their callback', await waitText(new RegExp(host + ' has your invoice for 1,500 sats')) && claims.length === 1 && claims[0].k1 === 'deadbeef' && claims[0].pr === 'lnbc1500n1testinvoice1', JSON.stringify(claims)); + check('…and the wallet watches for the money', (await page.evaluate(() => test.watch)) === 'ln1'); + await page.evaluate(() => test.settle(true)); + check('paid: Received', await waitText(/Received 1,500 sats/) && !!(await page.$('.check-badge'))); + await click('Done'); await pause(50); + check('Done closes it', !!(await page.$('#closed'))); + + console.log('[a fixed amount]'); + await page.evaluate((s) => test.paste(s), lnurl('/withdraw-fixed')); + check('a fixed amount is stated, with no field', await waitText(new RegExp(host + ' will pay you 21 sats')) && !(await page.$('input[type=number]'))); + await click('Withdraw'); + check('withdrawn at that amount', await waitText(/has your invoice for 21 sats/) && claims[1].pr === 'lnbc21n1testinvoice2', JSON.stringify(claims)); + await click('Back'); await pause(50); + check('Back before it is paid stops watching', (await page.evaluate(() => test.watch)) === null && !!(await page.$('#closed'))); + + console.log('[a site that refuses]'); + await page.evaluate((s) => test.paste(s), lnurl('/withdraw-spent')); + await waitText(/will pay you 1 sats/); await click('Withdraw'); + check("their reason is shown, with Try again", await waitText(/already used/) && /Try again/.test(await text()), await text()); + check('no page errors', errors.length === 0, errors.join('; ')); + console.log('\n✅ a withdraw lnurl pays us'); +} catch (e) { console.error('\n❌', e.message); process.exitCode = 1; } finally { await browser.close(); server.stop(true); } 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-controls-test.js b/tools/pager-controls-test.js new file mode 100644 index 0000000..a02591d --- /dev/null +++ b/tools/pager-controls-test.js @@ -0,0 +1,114 @@ +// The media pager's buttons under a finger: the sound button mutes on ONE +// tap (the browser's click and the pager's pending tap both clicked it, so +// it toggled twice and a clip could not be muted — Adam, 2026-10-16), and +// the reaction button offers the emoji sheet, over the pager, as the feed's +// does; the emoji picked shows on the button, and a tap again takes it back. +// Run: bun tools/pager-controls-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 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 = { tall: clip('tall', '180x320') }; +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: (req) => { + const path = new URL(req.url).pathname; + if (path === '/tall.webm') return new Response(Bun.file(CLIPS.tall), { 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 seeded = [post('a tall one ' + server.url.origin + '/tall.webm', 100), + ...Array.from({ length: 8 }, (_, 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', '--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; }; +const centre = (sel) => page.evaluate((s) => { const r = document.querySelector(s).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }, sel); +const tap = async (sel) => { const c = await centre(sel); await page.touchscreen.tap(c.x, c.y); await sleep(450); }; // (past the pending tap's 120 ms) +const sound = () => page.evaluate(() => { const v = document.querySelector('.mp-slide video.mp-media'); return { quiet: document.querySelector('.media-pager').classList.contains('quiet'), muted: v ? v.muted : null }; }); +const likeBtn = () => page.evaluate(() => { const b = document.querySelector('.mp-like'); return { on: b.classList.contains('on'), text: b.querySelector('.i').textContent.trim(), heart: !!b.querySelector('.i svg') }; }); + +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 clip is in the feed', await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 20000)); + await page.evaluate(() => document.querySelector('.notes-feed video.note-video').scrollIntoView({ block: 'center' })); + await sleep(500); + const at = await page.evaluate(() => { const b = document.querySelector('.notes-feed video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + await page.touchscreen.tap(at.x, at.y); + check('the clip opens in the pager', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide video.mp-media'), null, 6000)); + await sleep(1000); // (a click within 800 ms of the touch that opened it is that touch's) + // the buttons sit in the details, up on a tap + await tap('.mp-slide video.mp-media'); + check('a tap brings the details up', await waitFor(() => document.querySelector('.media-pager').classList.contains('show'), null, 2000)); + + console.log('\n[the sound button: one tap, one change]'); + const s0 = await sound(); + check('it starts loud', !s0.quiet && s0.muted === false, JSON.stringify(s0)); + await tap('.mp-sound'); + let s = await sound(); + check('one tap mutes it — and it stays muted', s.quiet && s.muted === true, JSON.stringify(s)); + await sleep(500); s = await sound(); + check('…still muted half a second on', s.quiet && s.muted === true, JSON.stringify(s)); + await tap('.mp-sound'); + s = await sound(); + check('a tap again unmutes', !s.quiet && s.muted === false, JSON.stringify(s)); + + console.log('\n[the reaction button: a menu of emoji, over the pager]'); + check('the details are still up', await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show'))); + await tap('.mp-like'); + check('a tap opens the emoji sheet', await waitFor(() => !!document.querySelector('.confirm-pop-backdrop.over-pager .msg-sheet-emojis'), null, 3000)); + check('…on top of the pager', await page.evaluate(() => { const b = document.querySelector('.msg-sheet-emojis button'); const r = b.getBoundingClientRect(); const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return !!hit && !!hit.closest('.msg-sheet-emojis'); })); + check('…with several to pick from, and a way to more', await page.evaluate(() => document.querySelectorAll('.msg-sheet-emojis button').length >= 5 && !!document.querySelector('.msg-sheet-emojis .more'))); + check('the pager is still there beneath', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find((b) => b.textContent.trim() === '🔥').click()); + check('picking 🔥 closes the sheet', await waitFor(() => !document.querySelector('.confirm-pop-backdrop.over-pager'), null, 3000)); + check('…and the button shows 🔥, lit', await waitFor(() => { const b = document.querySelector('.mp-like'); return b.classList.contains('on') && b.querySelector('.i').textContent.trim() === '🔥'; }, null, 4000), JSON.stringify(await likeBtn())); + check('…still in the pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + await tap('.mp-like'); + check('a tap again takes the reaction back: the heart, unlit', await waitFor(() => { const b = document.querySelector('.mp-like'); return !b.classList.contains('on') && !!b.querySelector('.i svg'); }, null, 4000), JSON.stringify(await likeBtn())); + await tap('.mp-like'); + await waitFor(() => !!document.querySelector('.confirm-pop-backdrop.over-pager'), null, 3000); + await page.keyboard.press('Escape'); + check('Escape closes the sheet, not the pager', await waitFor(() => !document.querySelector('.confirm-pop-backdrop.over-pager'), null, 2000) && await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + 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 buttons under a finger' : '\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 new file mode 100644 index 0000000..34c89b6 --- /dev/null +++ b/tools/pager-gone-picture-test.js @@ -0,0 +1,72 @@ +// A picture whose host is down (its server answers 502, so neither a copy +// 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'; +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; +const PICS = { big1: png('2400x1800'), big3: png('2000x2000') }; +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 hits = []; +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 === '/big2.png') { hits.push('big2'); return new Response('Bad Gateway', { status: 502 }); } // the host is down + const m = /^\/(big\d)\.png$/.exec(path); + if (m) { hits.push(m[1]); return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big2'), 200), post('third big ' + U('big3'), 300), + ...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); +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 i = s.querySelector('img.mp-media'); const r = s.getBoundingClientRect(); return { k: s.getAttribute('data-k').split('/').pop(), src: (i.getAttribute('src') || '').replace(/^blob:.*/, 'blob:'), cur: s.classList.contains('mp-cur'), willChange: getComputedStyle(i).willChange, on: Math.abs(r.top) < 4 }; })); +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 feed paints its copies of the pictures', await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].filter((i) => i.getAttribute('src').startsWith('blob:')).length >= 2, null, 25000)); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('first big')); r.scrollIntoView({ block: 'center' }); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }); + await sleep(300); + 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: 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 is passed over' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-loading-test.js b/tools/pager-loading-test.js new file mode 100644 index 0000000..42a5344 --- /dev/null +++ b/tools/pager-loading-test.js @@ -0,0 +1,82 @@ +// Exercise the production refill scheduler with delayed and sparse sources. +// Run: bun tools/pager-loading-test.js +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import assert from 'node:assert/strict'; + +const source = readFileSync(new URL('../src/features/messages.js', import.meta.url), 'utf8'); +const scheduler = source.slice(source.indexOf(' const PAGER_INITIAL ='), source.indexOf(' // Slides for feed items', source.indexOf(' const PAGER_INITIAL ='))); +function fixture(kind = 'images', grid = false) { + let clock = 100000, calls = 0, pending = null, count = 3; + const groups = Array.from({ length: 300 }, () => ({})); + const cache = { shown: 3, notes: Array(3), stopped: false }; + const index = { until: 10000, images: new Map(), videos: new Map() }; + const p = { kind, cur: { closest: () => groups[0] } }; + const more = async () => { + calls++; + if (pending) await pending; + count += 10; + cache.shown += 10; cache.notes.length += 10; + index.until -= 100; + }; + const src = { c: cache, more, done: () => false }; + const ctx = vm.createContext({ + 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 }, + }); + vm.runInContext(scheduler, ctx); + return { + ctx, p, src, cache, index, load: () => ctx.pagerLoadAhead(), + go: (i) => { p.cur = { closest: () => groups[i] }; }, + advance: () => { clock += 11000; }, + delay: () => { let resolve; pending = new Promise((r) => { resolve = r; }); return () => { pending = null; resolve(); }; }, + count: () => count, calls: () => calls, + }; +} + +for (const grid of [false, true]) for (const kind of ['images', 'videos']) { + const f = fixture(kind, grid); + await f.load(); + assert.equal(f.calls(), 3, 'opening starts loading immediately to at least 30 posts'); + f.advance(); await f.load(); + assert.equal(f.calls(), 3, 'a full buffer does not download forever'); + f.go(29); f.advance(); + const finish = f.delay(); + const loading = f.load(); + assert.equal(f.calls(), 4, 'refill starts well before the end'); + f.go(0); await f.load(); + assert.equal(f.calls(), 4, 'wrapping during a request does not start a concurrent one'); + finish(); await loading; + assert.equal(f.count(), 53, 'refill continues after wrapping, keeping 20 beyond the furthest post'); + console.log('✓ early loading and wrap refill:', grid ? 'grid' : 'feed/profile', kind); +} +{ + const f = fixture(); + f.src.more = async () => { f.cache.notes.length++; }; + await f.load(); + assert.equal(f.cache.notes.length, 11, 'sparse results are limited to eight requests per run'); + f.advance(); await f.load(); + assert.equal(f.cache.notes.length, 19, 'timer can resume sparse results from the first post'); +} +{ + const f = fixture(); + let calls = 0; + f.src.more = async () => { calls++; }; + await f.load(); await f.load(); + assert.equal(calls, 1, 'an empty result backs off'); + f.advance(); await f.load(); + assert.equal(calls, 2, 'an empty source can retry'); + f.src.done = () => true; + f.advance(); await f.load(); + assert.equal(calls, 2, 'an exhausted source is not requested'); +} +{ + const f = fixture(); + const finish = f.delay(), loading = f.load(); + f.ctx.pager = { kind: 'videos' }; + finish(); await loading; + assert.equal(f.calls(), 1, 'closing or switching pagers stops the old refill'); +} +console.log('✓ sparse sources, backoff, exhaustion, and pager lifecycle'); 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-resize-test.js b/tools/pager-resize-test.js new file mode 100644 index 0000000..07fcbba --- /dev/null +++ b/tools/pager-resize-test.js @@ -0,0 +1,134 @@ +// The pager keeps its post when the screen changes size: the page going +// full screen for it (a taller window, the address bar gone), or a turn of +// the phone. Posts are a screen tall, so a pixel scroll position deep in +// the pager lands in another post after a resize — Adam's full-screen +// button on a clip 23 posts down showed him another post's picture. The +// resize can come before the observer has even named the first slide. +// Run: bun tools/pager-resize-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; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), e: png('240x180') }; + +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: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|e)\d*\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [ + post('two pictures ' + U('a1') + ' ' + U('a2'), 100), + post('one picture ' + U('b'), 200), + post('only words here, nothing to look at', 300), + post('a last picture ' + U('d'), 400), + ...Array.from({ length: 12 }, (_, i) => post('more ' + i + ' ' + U('e' + i), 500 + i * 60)), // a dozen more, one picture each, for a flight +]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4'] /* a pointer that hovers: headless has none */ }); +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; }; +// the slide the pager rests on, by its picture +const curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => rested(name).then(async (r) => { await sleep(260); return r; }); // a breath after: the wheel's gate between steps +const rested = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const openPic = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.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 i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); + return at.y0; +}; +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +try { + await page.setViewport({ width: 1280, height: 900 }); + 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 pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); + const cdp = await page.target().createCDPSession(); + // a trackpad's run: small deltas, page-side — a CDP mouseWheel is always a 120-unit notch to Chrome + const pad = (dy, n) => page.evaluate(async ([dy, n]) => { const sc = document.querySelector('.mp-scroll'); for (let i = 0; i < n; i++) { const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const ev = new WheelEvent('wheel', { deltaY: dy, deltaX: 0, deltaMode: 0, bubbles: true, cancelable: true, clientX: 640, clientY: 450 }); Object.defineProperty(ev, 'wheelDeltaY', { value: -Math.round(dy * 1.2) }); s.querySelector('img.mp-media').dispatchEvent(ev); await new Promise((r) => setTimeout(r, 16)); } }, [dy, n]); + const pos = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); return sc ? Math.round(sc.scrollTop) + '/' + sc.clientHeight + ' glide:' + !!sc._glide + ' snap:' + sc.style.scrollSnapType : 'none'; }); + const wheel = (dy, { dx = 0, mods = 0 } = {}) => cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 640, y: 450, deltaX: dx, deltaY: dy, modifiers: mods }); + const zoomOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /scale\(([\d.]+)\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? Number(m[1]) : 1; }); + const shiftOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? [Number(m[1]), Number(m[2])] : [0, 0]; }); + + console.log('\n[the screen grows right as the pager opens]'); + // the feed draws a window of rows: scroll down until the post is drawn + for (let i = 0; i < 40; i++) { + if (await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); if (!r) return false; r.scrollIntoView({ block: 'center' }); return true; }, 'more 8')) break; + await page.evaluate(() => window.scrollBy(0, 1500)); await sleep(300); + } + await sleep(500); + const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, 'more 8'); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); + // the window grows (full screen) before the pager's observer has named a slide + await page.setViewport({ width: 1280, height: 1040 }); + check('the pager rests on the picture tapped, eleven posts down', await resting('e8.png'), await curPic() + ' ' + await pos()); + await sleep(600); + check('…and stays there', (await curPic()) === 'e8.png', await curPic()); + console.log('\n[a later resize]'); + await page.keyboard.press('ArrowDown'); await resting('e9.png'); + await page.setViewport({ width: 1280, height: 780 }); + check('the window shrinks: still on the post the reader moved to', await resting('e9.png'), await curPic() + ' ' + await pos()); + await page.setViewport({ width: 1280, height: 1100 }); + check('…and grows again: still there', await resting('e9.png'), await curPic() + ' ' + await pos()); + await page.keyboard.press('ArrowUp'); await resting('e8.png'); + await page.setViewport({ width: 900, height: 1200 }); + check('a different shape: still on the post', await resting('e8.png'), await curPic() + ' ' + await pos()); + console.log('\n[a wide window: the words and counts keep to the middle]'); + await page.setViewport({ width: 1920, height: 600 }); + await resting('e8.png'); + await page.keyboard.press('ArrowDown'); await resting('e9.png'); // moved on from the start: a tap shows the details (on the start it closes) + await page.mouse.click(960, 300); await sleep(400); + const geo = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const r = (q) => { const e = s.querySelector(q); const b = e.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.right)]; }; return { show: document.querySelector('.media-pager').classList.contains('show'), left: r('.mp-left'), counts: r('.mp-counts'), meta: r('.mp-meta') }; }); + check('the details are up', geo.show); + check('the words start near the middle column, not the screen\'s edge', geo.left[0] >= 560 && geo.left[0] <= 600, JSON.stringify(geo)); + check('the counts sit at the column\'s edge, not the screen\'s corner', geo.counts[1] >= 1320 && geo.counts[1] <= 1360, JSON.stringify(geo)); + check('the gradient still spans the screen', geo.meta[0] === 0 && geo.meta[1] === 1920, JSON.stringify(geo)); + await page.setViewport({ width: 390, height: 844 }); await sleep(300); + const narrow = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const b = s.querySelector('.mp-left').getBoundingClientRect(); return [Math.round(b.left), Math.round(s.querySelector('.mp-counts').getBoundingClientRect().right)]; }); + check('a phone keeps the full width', narrow[0] <= 16 && narrow[1] >= 370, JSON.stringify(narrow)); + 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 keeps its post through a resize' : '\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-sharpen-test.js b/tools/pager-sharpen-test.js new file mode 100644 index 0000000..ea5c878 --- /dev/null +++ b/tools/pager-sharpen-test.js @@ -0,0 +1,78 @@ +// A picture in the media pager flies in as the feed's column copy (the one +// the feed already decoded) and is sharpened to the original once its slide +// comes to rest; only the slide at rest carries the zoom layer. (Originals +// decoded and uploaded as they passed made a flight stutter — Adam, +// 2026-10-16.) Run: bun tools/pager-sharpen-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; +const PICS = { big1: png('2400x1800'), big2: png('1800x2400'), big3: png('2000x2000') }; +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 hits = []; +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)); + const m = /^\/(big\d)\.png$/.exec(path); + if (m) { hits.push(m[1]); return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big2'), 200), post('third big ' + U('big3'), 300), + ...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(); +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 i = s.querySelector('img.mp-media'); const r = s.getBoundingClientRect(); return { k: s.getAttribute('data-k').split('/').pop(), src: (i.getAttribute('src') || '').replace(/^blob:.*/, 'blob:'), cur: s.classList.contains('mp-cur'), willChange: getComputedStyle(i).willChange, on: Math.abs(r.top) < 4 }; })); +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 feed paints its copies of the pictures', await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].filter((i) => i.getAttribute('src').startsWith('blob:')).length >= 2, null, 25000)); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('first big')); r.scrollIntoView({ block: 'center' }); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }); + await sleep(300); + 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)); + let s = await slides(); + check('every slide carries the copy, not the original', s.length >= 3 && s.every((x) => x.src === 'blob:' || x.src === ''), JSON.stringify(s.map((x) => x.src))); + check('the slide on screen is the current one, alone with the zoom layer', s.filter((x) => x.cur).length === 1 && s.find((x) => x.cur).on && s.every((x) => (x.willChange === 'transform') === x.cur), JSON.stringify(s.map((x) => [x.cur, x.willChange]))); + check('…sharpened to the original once 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), JSON.stringify((await slides()).map((x) => x.src))); + s = await slides(); + check('the others stay on their copies', s.filter((x) => !x.cur).every((x) => x.src === 'blob:' || x.src === ''), JSON.stringify(s.map((x) => x.src))); + await page.keyboard.press('ArrowDown'); + check('down: the next slide becomes current', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big2.png'); }, null, 4000)); + check('…and is sharpened in its turn', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big2.png'); }, null, 5000), JSON.stringify((await slides()).map((x) => x.src))); + s = await slides(); + check('only one slide has the zoom layer', s.filter((x) => x.willChange === 'transform').length === 1 && s.find((x) => x.willChange === 'transform').k === 'big2.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✅ pager slides fly as copies and sharpen at rest' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js new file mode 100644 index 0000000..707aad6 --- /dev/null +++ b/tools/pager-swipe-fly-test.js @@ -0,0 +1,181 @@ +// 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 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'; +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; +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 }); +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 (/^\/p\d+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/p' + n + '.png'; +const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [], content: 'picture post ' + (i + 1) + ' ' + U(i + 1) }, SK)); + +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); +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; }; +// 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 < 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 +// simulation takes a round trip per move on top, so a flick through it is +// never faster than a stroll. +const swipe = async (px, ms, steps = 6) => { + if (ms >= 100) { + await page.touchscreen.touchStart(195, 700); + for (let i = 1; i <= steps; i++) { await page.touchscreen.touchMove(195, 700 - px * i / steps); await sleep(ms / steps); } + await page.touchscreen.touchEnd(); + return; + } + await page.evaluate(([px, ms, steps]) => { + 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)] })); + // on a schedule from the start, not a pause after each move: a move's + // handling (the slides following the finger) takes its own time + const t0 = performance.now(); + fire('touchstart', 700); + for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); fire('touchmove', 700 - px * i / steps); } + 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' }); + 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 pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000)); + 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, 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(); + check('a 320px swipe over 130ms: the next post', (await at()) === 2, String(await at())); + + 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 coasts round to the start]'); + while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); } + const before = await at(); + await swipe(600, 80, 3); await settled(); + check('from post ' + before + ' (a flight\'s worth would be past the end), a flick comes round to the first post', before >= 7 && (await at()) === 1, String(await at())); + await swipe(-600, 80, 3); await settled(); + check('…and a flick back up from the first comes round to the last', (await at()) === N, String(await at())); + await swipe(600, 80, 3); await settled(); + check('…a flick down from the last, to the first again', (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 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(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'); +process.exit(ok ? 0 : 1); 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/pager-wheel-test.js b/tools/pager-wheel-test.js new file mode 100644 index 0000000..45a5115 --- /dev/null +++ b/tools/pager-wheel-test.js @@ -0,0 +1,193 @@ +// The media pager with a mouse (a laptop): a wheel notch is a step — down +// the next post, up the one before, sideways (or shift+wheel) along a post's +// pictures — a trackpad's run of small deltas one step per run, a spin of +// the wheel not five; ctrl+wheel (a trackpad's pinch) zooms the picture, +// the wheel pans it while zoomed and pages again at 1×; and arrows at the +// edges for the pointer, the sideways pair only where the post has more. +// Run: bun tools/pager-wheel-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; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), e: png('240x180') }; + +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: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|e)\d*\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [ + post('two pictures ' + U('a1') + ' ' + U('a2'), 100), + post('one picture ' + U('b'), 200), + post('only words here, nothing to look at', 300), + post('a last picture ' + U('d'), 400), + ...Array.from({ length: 12 }, (_, i) => post('more ' + i + ' ' + U('e' + i), 500 + i * 60)), // a dozen more, one picture each, for a flight +]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4'] /* a pointer that hovers: headless has none */ }); +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; }; +// the slide the pager rests on, by its picture +const curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => rested(name).then(async (r) => { await sleep(260); return r; }); // a breath after: the wheel's gate between steps +const rested = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const openPic = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.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 i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); + return at.y0; +}; +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +try { + await page.setViewport({ width: 1280, height: 900 }); + 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 pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); + const cdp = await page.target().createCDPSession(); + // a trackpad's run: small deltas, page-side — a CDP mouseWheel is always a 120-unit notch to Chrome + const pad = (dy, n) => page.evaluate(async ([dy, n]) => { const sc = document.querySelector('.mp-scroll'); for (let i = 0; i < n; i++) { const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const ev = new WheelEvent('wheel', { deltaY: dy, deltaX: 0, deltaMode: 0, bubbles: true, cancelable: true, clientX: 640, clientY: 450 }); Object.defineProperty(ev, 'wheelDeltaY', { value: -Math.round(dy * 1.2) }); s.querySelector('img.mp-media').dispatchEvent(ev); await new Promise((r) => setTimeout(r, 16)); } }, [dy, n]); + const pos = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); return sc ? Math.round(sc.scrollTop) + '/' + sc.clientHeight + ' glide:' + !!sc._glide + ' snap:' + sc.style.scrollSnapType : 'none'; }); + const wheel = (dy, { dx = 0, mods = 0 } = {}) => cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 640, y: 450, deltaX: dx, deltaY: dy, modifiers: mods }); + const zoomOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /scale\(([\d.]+)\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? Number(m[1]) : 1; }); + const shiftOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? [Number(m[1]), Number(m[2])] : [0, 0]; }); + + console.log('\n[a wheel notch is a step]'); + await openPic('two pictures'); + check('the picture opens in the pager', await resting('a1.png'), await curPic()); + check('opening fills the media buffer without scrolling to the end', await waitFor(() => document.querySelectorAll('.mp-group').length === 15, null, 8000)); + check('background loading keeps the first picture on screen', await curPic() === 'a1.png'); + await page.mouse.move(640, 450); + await wheel(100); + check('a notch down: the next post\'s picture', await resting('b.png'), await curPic()); + await wheel(100); + check('another: the next, past a post with none', await resting('d.png'), await curPic()); + await wheel(-100); + check('a notch up: back one post', await resting('b.png'), await curPic()); + await wheel(-100); + check('up again: the first post', await resting('a1.png'), await curPic()); + await wheel(0, { dx: 100 }); + check('sideways: the post\'s second picture', await resting('a2.png'), await curPic()); + await wheel(0, { dx: 100 }); await sleep(300); + check('sideways at the post\'s last: stays', (await curPic()) === 'a2.png', await curPic()); + await wheel(-100, { mods: 8 }); + check('shift+wheel up: back to its first picture', await resting('a1.png'), await curPic()); + await wheel(-100, { mods: 8 }); await sleep(300); + check('shift+wheel at the first: stays', (await curPic()) === 'a1.png', await curPic()); + await wheel(100, { mods: 8 }); await resting('a2.png'); + await sleep(300); + console.log('\n[a spin flies, and lands snapped]'); + // posts with pictures, in order: a (two), b, d, e0 … e11 — a spin of five + // notches is 1 + 1 + 2 + 2 + 2 = 8 posts on from a: e5 + for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); } + await sleep(250); + const mid = await pos(); + check('mid-flight: between posts, gliding', /glide:true/.test(mid) && (await curPic()) === 'between', mid); + check('a spin of five notches flies eight posts on and lands snapped', await resting('e5.png'), await curPic() + ' ' + await pos()); + check('the landing is arrived at: the sideways arrows know the post', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('has-right'))); + for (let i = 0; i < 5; i++) { await wheel(-100); await sleep(10); } + check('a spin back flies back the same way', await resting('a2.png'), await curPic() + ' ' + await pos()); + // a notch against a flight catches it on the post it is passing + for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); } + await sleep(200); + await wheel(-100); + await sleep(900); + const caught = await curPic(); + check('a notch back mid-flight catches it, snapped on a post short of the target', caught !== 'between' && !['e5.png', 'a1.png', 'a2.png'].includes(caught), caught + ' ' + await pos()); + await sleep(300); + await wheel(-100); await resting(caught === 'b.png' ? 'a2.png' : caught === 'd.png' ? 'b.png' : caught === 'e0.png' ? 'd.png' : 'e' + (Number(caught[1]) - 1) + '.png'); + // back to b for what follows + while ((await curPic()) !== 'b.png') { await wheel((await curPic()) === 'a2.png' || (await curPic()) === 'a1.png' ? 100 : -100); await sleep(500); } + await sleep(300); + + console.log('\n[a trackpad\'s run is one step]'); + await sleep(300); + await pad(9, 14); + check('a run of small deltas steps once', await resting('d.png'), await curPic() + ' ' + await pos()); + await sleep(400); + check('…and only once', (await curPic()) === 'd.png', await curPic()); + await sleep(300); + await pad(-9, 14); + check('a run back steps back', await resting('b.png'), await curPic()); + + console.log('\n[ctrl+wheel zooms, the wheel pans, 1× pages]'); + await sleep(400); + await wheel(-100, { mods: 2 }); await sleep(100); + const z1 = await zoomOf(); + check('ctrl + a notch up zooms the picture in', z1 > 1.2, 'scale ' + z1); + await wheel(-100, { mods: 2 }); await sleep(100); + check('…and more', (await zoomOf()) > z1, 'scale ' + await zoomOf()); + const before = await shiftOf(); + await wheel(100); await sleep(300); + check('a plain notch while zoomed pans, it does not page', (await curPic()) === 'b.png' && JSON.stringify(await shiftOf()) !== JSON.stringify(before), await curPic() + ' ' + JSON.stringify(before) + ' → ' + JSON.stringify(await shiftOf())); + for (let i = 0; i < 6; i++) { await wheel(100, { mods: 2 }); await sleep(30); } + await sleep(100); + check('ctrl + notches down zoom back out to 1×', (await zoomOf()) === 1, 'scale ' + await zoomOf()); + await sleep(300); + await wheel(100); + check('at 1× the wheel pages again', await resting('d.png'), await curPic()); + + console.log('\n[arrows for the pointer]'); + const shown = (sel) => page.evaluate((s) => { const b = document.querySelector(s); return !!b && getComputedStyle(b).display !== 'none' && b.getBoundingClientRect().width > 0; }, sel); + check('up and down arrows are on the pager', await shown('.mp-arrow.mp-up') && await shown('.mp-arrow.mp-down')); + check('a post with one picture shows no sideways arrows', !(await shown('.mp-arrow.mp-ar-left')) && !(await shown('.mp-arrow.mp-ar-right'))); + await page.click('.mp-arrow.mp-up'); + check('the up arrow: back one post', await resting('b.png'), await curPic()); + await page.click('.mp-arrow.mp-up'); + check('up again: the first post, on the picture it was left at', await resting('a2.png'), await curPic()); + await sleep(200); + check('on its last picture: a left arrow, no right', await shown('.mp-arrow.mp-ar-left') && !(await shown('.mp-arrow.mp-ar-right'))); + await page.click('.mp-arrow.mp-ar-left'); + check('the left arrow: its first picture', await resting('a1.png'), await curPic()); + await sleep(200); + check('on its first: a right arrow, no left', await shown('.mp-arrow.mp-ar-right') && !(await shown('.mp-arrow.mp-ar-left'))); + await page.click('.mp-arrow.mp-ar-right'); + check('the right arrow: its second picture', await resting('a2.png'), await curPic()); + check('an arrow\'s click is not a tap on the picture (no details, still open)', await page.evaluate(() => { const p = document.querySelector('.media-pager'); return !!p && !p.classList.contains('show'); })); + await page.click('.mp-arrow.mp-down'); + check('the down arrow: the next post', await resting('b.png'), await curPic()); + await page.keyboard.press('Escape'); + check('Escape still closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); + 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✅ pager: wheel steps, ctrl+wheel zooms, arrows for the pointer' : '\n❌ failed'); +process.exit(ok ? 0 : 1); 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 new file mode 100644 index 0000000..aac681a --- /dev/null +++ b/tools/profile-pager-test.js @@ -0,0 +1,136 @@ +// A picture tapped on someone's profile page opens in the media pager over +// THEIR pictures — the posts of their timeline, in its order, nobody +// else's — and closing it after moving on lands the profile on that post. +// (It opened alone, in the lightbox, before: no pager on a profile.) +// Run: bun tools/profile-pager-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; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), x: png('200x200') }; + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk = SK) => 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: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|x)\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [ + post('bob picture ' + U('x'), 50, SK2), // someone else's, newest in the feed: not in Alice's pager + post('two pictures ' + U('a1') + ' ' + U('a2'), 100), + post('one picture ' + U('b'), 200), + post('only words here, nothing to look at', 300), + post('a last picture ' + U('d'), 400), + ...Array.from({ length: 12 }, (_, 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); +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; }; +// the slide the pager rests on, by its picture +const curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const openPic = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.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 i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); + return at.y0; +}; +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +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, other]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk], ['p', other]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', about: 'alice bio here', t: Date.now() }, [other]: { name: 'Bob Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded, OTHER]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); + + console.log('\n[a picture on a profile page]'); + // Alice's profile, from her face on a feed row + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('one picture')); r.scrollIntoView({ block: 'center' }); }); + await sleep(300); + const face = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('one picture')).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); + await page.mouse.click(face.x, face.y); + check('her profile opens, with her posts', await waitText('alice bio here', 8000) && await waitFor(() => [...document.querySelectorAll('.notes-feed [data-key]')].some((r) => r.innerText.includes('two pictures')), null, 8000)); + check('…and not Bob\'s', !(await page.evaluate(() => document.body.innerText.includes('bob picture')))); + await openPic('two pictures'); + check('her picture opens in the pager, on itself', await resting('a1.png'), await curPic()); + const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop())); + check('the pager holds her pictures only, in her timeline\'s order', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png', 'd.png']), JSON.stringify(keys)); + check('counter starts at the first post', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3'); + await page.keyboard.press('ArrowRight'); + check('right: the post\'s second picture', await resting('a2.png'), await curPic()); + check('sideways keeps the same post number', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3'); + await page.keyboard.press('ArrowDown'); + check('down: her next post with a picture', await resting('b.png'), await curPic()); + check('down advances by one post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 3'); + 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('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.keyboard.press('ArrowUp'); + check('up: her post before', await resting('b.png'), await curPic()); + await page.keyboard.press('Escape'); + check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); + await sleep(900); + check('…still on her profile', await page.evaluate(() => document.body.innerText.includes('alice bio here'))); + check('…landed on that post', (await rowTop('one picture')) !== null && (await rowTop('one picture')) < 120 && (await rowTop('one picture')) > -10, 'row top ' + (await rowTop('one picture'))); + await openPic('one picture'); + check('opened again on the picture it closed on', await resting('b.png'), await curPic()); + await tapPager(); await sleep(400); + check('a tap on the picture that opened it shows its details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); + await tapPager(); await sleep(600); + check('…and the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + 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 profile\'s pictures page through the pager' : '\n❌ failed'); +process.exit(ok ? 0 : 1); 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/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-pager-test.js b/tools/thread-pager-test.js new file mode 100644 index 0000000..0f99742 --- /dev/null +++ b/tools/thread-pager-test.js @@ -0,0 +1,134 @@ +// A picture tapped on a thread page opens in the media pager over the +// THREAD's pictures — the root's and its replies', in the page's order, +// nobody else's — with the caption and controls a tap away, and closing it +// leaves the thread on screen. (It opened alone, in the lightbox, before: +// a tap on it closed it, with no caption or controls — Adam, 2026-10-16.) +// Run: bun tools/thread-pager-test.js +import puppeteer from './puppeteer.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; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), x: png('200x200') }; +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 === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } }); + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|x)\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk = SK, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk); +const root = post('two pictures in the root ' + U('a1') + ' ' + U('a2'), 600); +const replyWords = post('a reply with only words', 500, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]); +const replyPic = post('a reply with a picture ' + U('b'), 400, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]); +const feedNotes = [ + post('someone else, newer, with a picture ' + U('x'), 100, SK2), + post('another post with a picture ' + U('d'), 200), + root, + ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 700 + i * 60)), +]; +const replies = [replyWords, replyPic]; +const profiles = [finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Picture Poster' }) }, SK), + finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Reply Writer' }) }, SK2)]; +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes: feedNotes, profiles, popUntil: now - 3600 }); + +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 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 curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const openPicIn = async (scope, word, nth = 0) => { + await page.evaluate(([sc, w]) => { const r = [...document.querySelectorAll(sc)].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, [scope, word]); + await sleep(400); + const at = await page.evaluate(([sc, w, n]) => { const r = [...document.querySelectorAll(sc)].find((x) => x.textContent.includes(w)); const i = r.querySelectorAll('img.note-img')[n].getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, [scope, word, nth]); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager, .lightbox'), null, 4000); +}; +const threadRowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.thread-page [data-zap-post]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(([notes, replies, profiles]) => { + class FakeSocket extends EventTarget { + constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set(); + setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); } + emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); } + message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); } + send(raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; } + if (m[0] !== 'REQ') return; + const [, id, f] = m; this.subs.add(id); + let evs = []; + const all = [...notes, ...replies]; + if (f['#e']) evs = replies.filter((e) => e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1]))); + else if (f.ids) evs = all.filter((e) => f.ids.includes(e.id)); + else if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) && (!f.authors || f.authors.includes(e.pubkey))).slice(0, f.limit || 100); + if (f.kinds?.includes(0)) evs = profiles.filter((p) => !f.authors || f.authors.includes(p.pubkey)); + setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30); + } + close() { this.readyState = 3; this.emit('close', new Event('close')); } + } + Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + window.WebSocket = FakeSocket; + }, [feedNotes, replies, profiles]); + await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' }); + check('the feed shows the posts', await waitFor(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 10, null, 20000)); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); r.scrollIntoView({ block: 'center' }); }); + await sleep(300); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); (r.querySelector('.note-text') || r).click(); }); + check('the thread opens', await waitFor(() => !!document.querySelector('.thread-page'), null, 5000)); + check('…with its replies', await waitFor(() => document.body.innerText.includes('a reply with a picture'), null, 8000)); + await sleep(500); + + console.log('\n[a picture on a thread page]'); + await openPicIn('.thread-page [data-zap-post]', 'two pictures in the root'); + check('it opens in the media pager, not the lightbox', await page.evaluate(() => !!document.querySelector('.media-pager') && !document.querySelector('.lightbox'))); + check('…on itself', await resting('a1.png'), await curPic()); + const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop())); + check('the pager holds the thread\'s pictures only, root first then the replies', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png']), JSON.stringify(keys)); + check('counter: first of two posts', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 2'); + check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show'))); + await tapPager(); await sleep(400); + check('a tap shows the caption and controls', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); + check('…with the author and the words', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return /Picture Poster/.test(s.textContent) && /two pictures in the root/.test(s.textContent); })); + await page.keyboard.press('ArrowRight'); + check('right: the root\'s second picture', await resting('a2.png'), await curPic()); + await page.keyboard.press('ArrowDown'); + check('down: the reply\'s picture', await resting('b.png'), await curPic()); + check('counter: second post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 2'); + check('no loading slide (nothing more to fetch)', await page.$('.mp-tail') === null); + await page.keyboard.press('Escape'); + check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + await sleep(600); + check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a reply with only words'))); + check('…with the reply\'s post on screen', (await threadRowTop('a reply with a picture')) !== null && (await threadRowTop('a reply with a picture')) >= -10 && (await threadRowTop('a reply with a picture')) < 844, 'row top ' + (await threadRowTop('a reply with a picture'))); + + console.log('\n[a reply\'s picture, and a tap then another]'); + await openPicIn('.thread-page [data-zap-post]', 'a reply with a picture'); + check('opens on the reply\'s picture', await resting('b.png'), await curPic()); + await tapPager(); await sleep(400); + check('a tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); + await tapPager(); await sleep(600); + check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-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✅ a thread\'s pictures page through the pager' : '\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/wn-only-device-test.js b/tools/wn-only-device-test.js new file mode 100644 index 0000000..a91262b --- /dev/null +++ b/tools/wn-only-device-test.js @@ -0,0 +1,129 @@ +// Settings → Nostr → Group invitations: a Marmot inviter (White Noise) +// picks ONE of your published KeyPackages, so the group lands on one device +// and nothing says which. The card lists the other keys of yours on the +// relays and "Invite this device only" retires them (a kind 5 naming each: +// NIP-09 `a` for the addressable 30443s, `e` for an old 443) and publishes +// a fresh one of ours. A device whose key was retired by another sees the +// deletion and stops putting a key back; the card says so there. +// Run: bun tools/wn-only-device-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { 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 html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5341, fetch: (req) => { const p = new URL(req.url).pathname; if (p.startsWith('/punks') || p === '/verify-worker.js' || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { status: 404 }); } 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(); +// a stand-in for every relay: the account's key packages and deletions are +// whatever the test says, an EVENT is recorded and OK'd, everything else is empty +await page.evaluateOnNewDocument(() => { + window.__published = []; window.__kps = []; window.__dels = []; window.__reqs = []; + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + const answer = (id, evs) => setTimeout(() => { + for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); + }, 0); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const out = []; + for (const f of m.slice(2)) { + window.__reqs.push(f); + const kinds = f.kinds || []; + if ((kinds.includes(30443) || kinds.includes(443)) && f.authors) out.push(...window.__kps.filter((e) => kinds.includes(e.kind) && f.authors.includes(e.pubkey)), ...window.__published.filter((e) => e.kind === 30443 && f.authors.includes(e.pubkey))); + if (kinds.includes(5) && f.authors) out.push(...window.__dels.filter((e) => f.authors.includes(e.pubkey) && (!f['#a'] || e.tags.some((t) => t[0] === 'a' && f['#a'].includes(t[1]))))); + } + answer(m[1], out); + return; + } + if (m[0] === 'EVENT' && m[1]) { + window.__published.push(m[1]); + // the relay honours the deletion: the keys it names are gone + if (m[1].kind === 5) { const ids = new Set(m[1].tags.filter((t) => t[0] === 'e').map((t) => t[1])); const as = new Set(m[1].tags.filter((t) => t[0] === 'a').map((t) => t[1])); window.__kps = window.__kps.filter((e) => !ids.has(e.id) && !as.has('30443:' + e.pubkey + ':' + (e.tags.find((t) => t[0] === 'd') || [])[1])); } + setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); + return; + } + } catch {} + return send(data); + }; + 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 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, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; }; +const openNostrSettings = async () => { + 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); +}; +const card = () => page.evaluate(() => { const c = document.querySelector('.wn-devices-card'); return c ? { state: c.querySelector('.wn-device-state')?.textContent || '', others: [...c.querySelectorAll('.wn-device')].map((e) => e.textContent), none: !!c.querySelector('.wn-no-others'), btn: !!c.querySelector('.wn-only-here') } : null; }); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.origin, { 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'); + const SK = privateKeyFromSeedWords(mn), ME = getPublicKey(SK); + const now = Math.floor(Date.now() / 1000); + const slotA = 'a'.repeat(64), slotB = 'b'.repeat(64); + const kp = (slot, client, ago) => finalizeEvent({ kind: 30443, created_at: now - ago, tags: [['d', slot], ['client', client], ['mls_protocol_version', '1.0']], content: 'AAAA' }, SK); + const OTHERS = [kp(slotA, 'White Noise Android', 7 * 86400), kp(slotB, 'coinos', 2 * 86400), finalizeEvent({ kind: 443, created_at: now - 20 * 86400, tags: [['client', 'MDK/0.7.1']], content: 'AAAA' }, SK)]; + await page.evaluateOnNewDocument((kps) => { window.__kps = kps; }, OTHERS); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + + console.log('\n[the card]'); + await openNostrSettings(); + check('Settings → Nostr has a Group invitations card', await waitFor(() => !!document.querySelector('.wn-devices-card'), 8000)); + check('…that lists the three other keys, newest first, by app', await waitFor(() => document.querySelectorAll('.wn-devices-card .wn-device').length === 3, 10000) && (await card()).others[0].startsWith('coinos') && (await card()).others[1].startsWith('White Noise Android') && (await card()).others[2].startsWith('MDK/0.7.1'), JSON.stringify(await card())); + check('…and says whether this device\'s key is up yet (a seed wallet publishes it at boot, racing the card)', /can be invited|no invitation key/i.test((await card()).state), (await card()).state); + + console.log('\n[Invite this device only]'); + await page.evaluate(() => document.querySelector('.wn-only-here').click()); + check('a kind 5 goes out', await waitFor(() => window.__published.some((e) => e.kind === 5), 15000)); + const del = await page.evaluate(() => window.__published.find((e) => e.kind === 5)); + check('…by the account, naming the two slots (a) and all three events (e)', del.pubkey === ME + && del.tags.some((t) => t[0] === 'a' && t[1] === '30443:' + ME + ':' + slotA) && del.tags.some((t) => t[0] === 'a' && t[1] === '30443:' + ME + ':' + slotB) + && OTHERS.every((o) => del.tags.some((t) => t[0] === 'e' && t[1] === o.id)), JSON.stringify(del.tags)); + check('…and a fresh key of this device', await waitFor(() => window.__published.some((e) => e.kind === 30443), 10000)); + const mine = await page.evaluate(() => window.__published.find((e) => e.kind === 30443)); + check('…which the deletion does not name', !del.tags.some((t) => t[0] === 'a' && t[1].endsWith(':' + (mine.tags.find((x) => x[0] === 'd') || [])[1]))); + check('the card now says this device can be invited, no others', await waitFor(() => /can be invited/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || '') && !!document.querySelector('.wn-devices-card .wn-no-others'), 10000), JSON.stringify(await card())); + + console.log('\n[retired by another device]'); + const mySlot = (mine.tags.find((x) => x[0] === 'd') || [])[1]; + const retire = finalizeEvent({ kind: 5, created_at: now + 60, tags: [['a', '30443:' + ME + ':' + mySlot], ['e', mine.id], ['k', '30443']], content: 'group invitations go to one device' }, SK); + await page.evaluateOnNewDocument((d) => { window.__dels = [d]; }, retire); + // the device asks the relays about this a few times a day: make it due now + await page.evaluate(async ([k, pk]) => { const db = await new Promise((res, rej) => { const r = indexedDB.open('coinos-marmot'); r.onsuccess = () => res(r.result); r.onerror = rej; }); const key = k + ':' + pk + '|dev'; const st = db.transaction('kv', 'readwrite').objectStore('kv'); const v = await new Promise((res) => { const g = st.get(key); g.onsuccess = () => res(g.result); }); const o = typeof v === 'string' ? JSON.parse(v) : v; o.retireCheckedAt = 0; await new Promise((res) => { const p = st.put(typeof v === 'string' ? JSON.stringify(o) : o, key); p.onsuccess = res; }); }, [base, ME]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await openNostrSettings(); + check('the card says this device no longer takes invitations', await waitFor(() => /no longer takes/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || ''), 12000), (await card())?.state); + await sleep(1500); + check('…and no key of this device was put back', !(await page.evaluate(() => window.__published.some((e) => e.kind === 30443)))); + await page.evaluate(() => document.querySelector('.wn-only-here').click()); + check('"Invite this device only" here takes the invitations back: a fresh key', await waitFor(() => window.__published.some((e) => e.kind === 30443), 12000)); + check('…and the card says it can be invited again', await waitFor(() => /can be invited/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || ''), 10000), (await card())?.state); + 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✅ one device takes the group invitations' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
