diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/feed-picture-pager-test.js | 6 | ||||
| -rw-r--r-- | tools/feed-wheel-reverse-test.js | 41 | ||||
| -rw-r--r-- | tools/image-zoom-test.js | 1 | ||||
| -rw-r--r-- | tools/lnurl-auth-test.js | 102 | ||||
| -rw-r--r-- | tools/lnurl-auth-unit-test.js | 42 | ||||
| -rw-r--r-- | tools/notif-report-fold-test.js | 117 | ||||
| -rw-r--r-- | tools/pager-close-test.js | 140 | ||||
| -rw-r--r-- | tools/pager-controls-test.js | 114 | ||||
| -rw-r--r-- | tools/pager-loading-test.js | 82 | ||||
| -rw-r--r-- | tools/pager-resize-test.js | 134 | ||||
| -rw-r--r-- | tools/pager-sharpen-test.js | 78 | ||||
| -rw-r--r-- | tools/pager-swipe-fly-test.js | 124 | ||||
| -rw-r--r-- | tools/pager-wheel-test.js | 193 | ||||
| -rw-r--r-- | tools/profile-pager-test.js | 135 | ||||
| -rw-r--r-- | tools/thread-pager-test.js | 134 | ||||
| -rw-r--r-- | tools/wn-only-device-test.js | 129 |
16 files changed, 1552 insertions, 20 deletions
diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js index 32625c9..ff69626 100644 --- a/tools/feed-picture-pager-test.js +++ b/tools/feed-picture-pager-test.js @@ -82,12 +82,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..632bbc6 100644 --- a/tools/feed-wheel-reverse-test.js +++ b/tools/feed-wheel-reverse-test.js @@ -46,24 +46,29 @@ for (const dir of [1, -1]) { 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'); settle(f); - // The turn back is a hunt: a quarter of a notch's travel, not a flight the other way. + // The turn back is a plain notch from rest: its full travel, no flight the other way. const back = (before - f.y) * dir; - assert(back > 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. + assert(back > 280 && back < 330, 'a reverse notch against a coast travels one notch: ' + back); + // For a moment after a reversal notches don't wind the speed up: a + // reversal and a quick second notch travel about two notches, not a flight. + let calm; + { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); // coasting again, the other way + const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); calm = (f.y - y) * dir; + assert(calm > 540 && calm < 700, 'a reversal and a quick second notch: about two notches of travel: ' + calm); } + // From rest, well after, the wind-up is back: the same two quick notches go further. + { f.advance(2500); const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > calm + 40, 'two quick notches with the wind-up go further: ' + s + ' vs ' + calm); } + // A spin that keeps going after a reversal winds up again from its third + // notch: a reversal plus four quick notches goes well past five plain ones. + let spin; + { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); + const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); spin = (f.y - y) * dir; + assert(spin > 5 * 300 + 150, 'a spin on after a reversal winds up again: ' + spin); } + // ...and the same five quick notches from rest go about as far (the two calm ones cost little). + { f.advance(2500); const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); const s = (f.y - y) * dir; + assert(s < spin * 1.25, 'five from rest vs five after a reversal: ' + s + ' vs ' + spin); settle(f, 200); } + // From rest a notch either way is a full push. { f.advance(2500); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'from rest a notch either way is a full push: ' + s); } + // A turn back against a coast that only just died is a reversal too (caught, no wind-up). + { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 520 && s < 700, 'reversing right after the coast dies: no wind-up: ' + s); } } -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)'); +console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a plain notch, two calm notches, then the wind-up is back'); diff --git a/tools/image-zoom-test.js b/tools/image-zoom-test.js index 3ada708..0a11f0c 100644 --- a/tools/image-zoom-test.js +++ b/tools/image-zoom-test.js @@ -19,6 +19,7 @@ const goBack = (fn) => { if (backDelay) setTimeout(() => { fn(); render(); }, backDelay); else { fn(); render(); } }; +const closeLayer = (field) => goBack(() => { ui[field] = null; }); function render() { morphChildren(document.querySelector('#app'), ui.lightbox ? [imageViewer()] : []); } window.test = { ui, render, get backCalls() { return backCalls; }, get backSawViewer() { return backSawViewer; }, delayBack(ms) { backDelay = ms; }, 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..0707a8e --- /dev/null +++ b/tools/lnurl-auth-unit-test.js @@ -0,0 +1,42 @@ +// 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'); diff --git a/tools/notif-report-fold-test.js b/tools/notif-report-fold-test.js new file mode 100644 index 0000000..678aefd --- /dev/null +++ b/tools/notif-report-fold-test.js @@ -0,0 +1,117 @@ +// Notifications: a reply or reaction from someone two of your follows +// reported is folded — the row says who it is from and what they did, who +// flagged them, and Show unfolds it — rather than dropped without a trace +// (Adam's phone, 2026-10-14: a reply Amethyst showed was missing here). A +// person one follow flagged, or a stranger nobody did, shows as ever. +// Run: bun tools/notif-report-fold-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.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 FRIEND1 = key(), FRIEND2 = key(), JOHNNY = key(), ONCE = key(), OKAY = key(); +const now = Math.floor(Date.now() / 1000); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5331, 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 rows = () => page.evaluate(() => [...document.querySelectorAll('.alert-row')].map((r) => (r.classList.contains('note-folded') ? 'FOLD ' : 'ROW ') + r.innerText.replace(/\s+/g, ' ').trim())); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5331/', { 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 ME = getPublicKey(privateKeyFromSeedWords(mn)); + const MINE = finalizeEvent({ kind: 1, created_at: now - 600, tags: [], content: 'my post, replied to' }, FRIEND1.sk); // stands in for a post of mine + const reply = (who, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['e', MINE.id, '', 'root'], ['p', ME, '', 'mention']], content }, who.sk); + const JREPLY = reply(JOHNNY, 'johnny says hello there', 100); + const JREACT = finalizeEvent({ kind: 7, created_at: now - 90, tags: [['e', MINE.id], ['p', ME]], content: '+' }, JOHNNY.sk); + const OREPLY = reply(ONCE, 'flagged by one friend only', 80); + const KREPLY = reply(OKAY, 'a reply from a fine person', 70); + const profile = (who, name) => finalizeEvent({ kind: 0, created_at: now - 1000, tags: [], content: JSON.stringify({ name }) }, who.sk); + const PROFILES = [profile(FRIEND1, 'mar'), profile(FRIEND2, 'Rich'), profile(JOHNNY, 'Johnny'), profile(ONCE, 'Once'), profile(OKAY, 'Okay')]; + const REPORTS = [ + finalizeEvent({ kind: 1984, created_at: now - 5000, tags: [['p', JOHNNY.pk, 'spam']], content: '' }, FRIEND1.sk), + finalizeEvent({ kind: 1984, created_at: now - 4000, tags: [['p', JOHNNY.pk, 'spam']], content: '' }, FRIEND2.sk), + finalizeEvent({ kind: 1984, created_at: now - 3000, tags: [['p', ONCE.pk, 'impersonation']], content: '' }, FRIEND1.sk), + ]; + await page.evaluateOnNewDocument((fx) => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const hit = []; + for (const f of m.slice(2)) { + const kinds = f.kinds || []; + if (kinds.includes(1984)) hit.push(...fx.reports.filter((e) => (f.authors || []).includes(e.pubkey))); + if (kinds.includes(0)) hit.push(...fx.profiles.filter((e) => (f.authors || []).includes(e.pubkey))); + if ((f['#p'] || []).includes(fx.me) && (kinds.includes(1) || kinds.includes(7))) hit.push(...fx.notifs.filter((e) => kinds.includes(e.kind))); + if (f.ids) hit.push(...[fx.mine, ...fx.notifs].filter((e) => f.ids.includes(e.id))); + } + setTimeout(() => { + for (const e of hit) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); + }, 0); + return; + } + if (m[0] === 'EVENT') return; + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); + }, { me: ME, mine: MINE, notifs: [JREPLY, JREACT, OREPLY, KREPLY], reports: REPORTS, profiles: PROFILES }); + await page.evaluate(([k, f1, f2]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', f1], ['p', f2]], c: '', at: Math.floor(Date.now() / 1000) })); + }, [base, FRIEND1.pk, FRIEND2.pk]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Notifications"]')?.click()); + let got = []; + for (let i = 0; i < 80; i++) { got = await rows(); if (got.length >= 4 && got.some((r) => r.startsWith('FOLD') && /mar, Rich|Rich, mar/.test(r))) break; await sleep(250); } + console.log('\n[a reply from someone two follows reported]'); + check('four rows: nothing is dropped', got.length === 4, got.length + ' rows'); + const jr = got.find((r) => r.includes('replied to you') && r.includes('Johnny')); + check('Johnny\'s reply is folded, saying who he is and what he did', !!jr && jr.startsWith('FOLD'), jr); + check('...naming the two follows who flagged him', !!jr && /Reported by (mar, Rich|Rich, mar)/.test(jr), jr); + check('...and its text is not shown', !!jr && !jr.includes('johnny says hello'), jr); + check('his reaction is folded too', got.some((r) => r.startsWith('FOLD') && r.includes('reacted')), got.join(' | ')); + check('a person one friend flagged shows as ever', got.some((r) => r.startsWith('ROW') && r.includes('flagged by one friend only'))); + check('a fine person\'s reply shows', got.some((r) => r.startsWith('ROW') && r.includes('a reply from a fine person'))); + await page.evaluate(() => { const fold = [...document.querySelectorAll('.alert-row.note-folded')].find((r) => r.innerText.includes('replied')); fold.querySelector('button').click(); }); + await sleep(400); + got = await rows(); + console.log('\n[Show]'); + check('Show unfolds the reply', got.some((r) => r.startsWith('ROW') && r.includes('johnny says hello there')), got.join(' | ')); + check('the reaction stays folded', got.some((r) => r.startsWith('FOLD') && r.includes('reacted'))); + await page.evaluate(() => [...document.querySelectorAll('.alert-row:not(.note-folded)')].find((r) => r.innerText.includes('johnny says hello')).click()); + check('tapping the unfolded reply opens its thread, with the reply in it', await waitText('johnny says hello there', 8000) && await page.evaluate(() => !document.querySelector('.alert-row') && !!document.querySelector('.note-folded') === false), (await text()).slice(0, 200).replace(/\n/g, ' / ')); + 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✅ reported authors\' notifications fold with Show' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-close-test.js b/tools/pager-close-test.js new file mode 100644 index 0000000..73aa15b --- /dev/null +++ b/tools/pager-close-test.js @@ -0,0 +1,140 @@ +// Closing the pager lands on the feed under it, whatever the browser's +// history says. The × was Back, and the entry Back reached isn't always the +// feed (the browser's older entries outlive a reload of the app in the +// background; entries merge at boot): it took the reader to the wallet, then +// to the chat list. Now landing anywhere but the page under the pager puts +// the reader on that page, and that entry becomes it. And the ordinary case +// still steps Back, so the phone's Back doesn't reopen the pager. +// Run: bun tools/pager-close-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 = '/tmp/coinos-tail-clip.webm'; +if (!existsSync(CLIP)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=180x320:rate=10', '-t', '20', '-c:v', 'libvpx', '-b:v', '100k', CLIP]); + +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 (/^\/c\d\.webm$/.test(path)) return new Response(Bun.file(CLIP), { 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'; +// what the phone has: two clips among words; what only the relays have (slowly): two older clips +const cached = [ + post('clip zero ' + U('c0'), 100), post('words between ' + 'lorem ipsum. '.repeat(6), 200), post('clip one ' + U('c1'), 300), +]; +const older = [post('clip two ' + U('c2'), 5000), post('more words ' + 'lorem ipsum. '.repeat(6), 5100), post('clip three ' + U('c3'), 5200)]; + +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(); +// The relays: the newest posts at once; anything asked for by age (until) comes 3 s later +await page.evaluateOnNewDocument(([fresh, old]) => { + const all = [...fresh, ...old]; + 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('#')); + window.__olderAsks = 0; + class FakeWS { + constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); } + send(raw) { + const m = JSON.parse(raw); + const reply = (x, ms = 5) => setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), ms); + if (m[0] === 'REQ') { + const filters = m.slice(2).filter((f) => !f.cache); + const slow = filters.some((f) => f.until); + if (slow) window.__olderAsks++; + const pool = slow ? all : fresh; + const ms = slow ? 3000 : 5; + for (const f of filters) { + const hits = pool.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at); + for (const e of f.limit ? hits.slice(0, f.limit) : hits) reply(['EVENT', m[1], e], ms); + } + reply(['EOSE', m[1]], ms + 5); + } else if (m[0] === 'EVENT') reply(['OK', m[1].id, true, '']); + else if (m[0] === 'COUNT') reply(['COUNT', m[1], { count: 0 }]); + } + close() { this.readyState = 3; this.onclose && this.onclose({ code: 1000 }); } + addEventListener(t, fn) { this['on' + t] = fn; } + removeEventListener() {} + } + FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3; + window.WebSocket = FakeWS; +}, [cached, 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) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +// what the pager rests on: a clip's name, or 'tail' +const AT = () => { const sc = document.querySelector('.mp-scroll'); if (!sc) return null; const s = [...sc.querySelectorAll('.mp-slide, .mp-tail')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !s ? 'between' : s.classList.contains('mp-tail') ? 'tail' : s.getAttribute('data-k').split('/').pop().replace('.webm', ''); }; +const at = () => page.evaluate(AT); +const resting = (name, ms = 5000) => waitFor(([f, n]) => eval(f)() === n, ['(' + AT + ')', name], ms); +const playing = (name) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => (x.getAttribute('data-k') || '').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v && !v.paused && v.currentTime > 0; }, name, 6000); +const tail = () => page.evaluate(() => { const t = document.querySelector('.mp-tail'); return t ? { text: t.textContent.trim(), done: t.classList.contains('done'), last: t === t.parentElement.lastElementChild } : null; }); + +const st = () => page.evaluate(() => ({ pager: !!document.querySelector('.media-pager'), feed: !!document.querySelector('.notes-feed'), chats: !!document.querySelector('.chat-list, .msg-list'), wallet: !document.querySelector('.notes-feed') && /receive/i.test(document.body.innerText), i: history.state && history.state.i })); +const openFirst = async () => { + const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + await sleep(400); + await page.mouse.click(r.x, r.y); + return waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); +}; +const close = async () => { await page.evaluate(() => document.querySelector('.mp-x').click()); await sleep(1200); }; +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, cached]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await sleep(800); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]').click()); + await sleep(800); + const chatsNav = await page.evaluate(() => history.state); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 }); + await sleep(1200); + + console.log('\n[the ordinary case: × steps Back, onto the feed]'); + const before = await page.evaluate(() => history.state.i); + check('the pager opens', await openFirst()); + await page.keyboard.press('ArrowDown'); await sleep(800); + await close(); + let s = await st(); + check('× lands on the feed', !s.pager && s.feed, JSON.stringify(s)); + check('…by stepping Back (the pager\'s entry gone, not one more on top)', s.i === before, s.i + ' vs ' + before); + + console.log('\n[the entry under the pager isn\'t the feed (it says: the chat list)]'); + await page.evaluate((nav) => history.replaceState({ ...history.state, nav: nav.nav }, ''), chatsNav); + check('the pager opens', await openFirst()); + await page.keyboard.press('ArrowDown'); await sleep(800); + await close(); + s = await st(); + check('× still lands on the feed, not the chat list', !s.pager && s.feed, JSON.stringify(s)); + const fixed = await page.evaluate(() => history.state && history.state.nav && history.state.nav.msgView); + check('…and that entry is the feed now', fixed === 'feed', String(fixed)); + check('the phone\'s Back from there leaves the feed (no pager reopening)', await (async () => { await page.evaluate(() => history.back()); await sleep(1000); return !(await st()).pager; })()); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ closing the pager lands on the feed under it' : '\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-loading-test.js b/tools/pager-loading-test.js new file mode 100644 index 0000000..76e2764 --- /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), + 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-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-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..d809793 --- /dev/null +++ b/tools/pager-swipe-fly-test.js @@ -0,0 +1,124 @@ +// On a phone, a fast flick up or down in the media pager flies past several +// posts and lands snapped on one, the way a spin of the wheel does; a +// gentler swipe is one post, as before; a fling at the end lands on the +// last post rather than wrapping past it; a finger put down mid-flight +// catches it. Run: bun tools/pager-swipe-fly-test.js +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 = 9, 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(); +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 < 40; i++) { const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } }; +// 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]); +}; +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, 8000)); + + 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); }; + 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(' ')); + const seen = await page.evaluate(() => document.querySelector('.mp-position').textContent); + check('the counter says where it landed', seen === landed + ' / ' + N, seen); + + console.log('\n[a flick near the end: the last post, not past it]'); + while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); } + const before = await at(); + await swipe(600, 80, 3); await settled(); + check('from post ' + before + ' (a flight\'s worth would be past the end), a flick lands on the last post', before >= 7 && (await at()) === N, String(await at())); + await swipe(600, 80, 3); await settled(); + check('…and one more flick at the end wraps to the first', (await at()) === 1, String(await at())); + + console.log('\n[a finger down mid-flight catches it]'); + await swipe(600, 80, 3); + await sleep(120); + const mid = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); + await page.touchscreen.touchStart(195, 500); + await page.touchscreen.touchMove(195, 480); await sleep(30); + await page.touchscreen.touchMove(195, 470); await sleep(200); + const held = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); + await page.touchscreen.touchEnd(); await settled(); + check('the flight stops under the finger', mid > 0 && Math.abs(held - mid) < 844, mid + ' → ' + held); + check('…and lands square on a post when it lifts', (await at()) !== 'between', String(await at())); + 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-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/profile-pager-test.js b/tools/profile-pager-test.js new file mode 100644 index 0000000..29341e7 --- /dev/null +++ b/tools/profile-pager-test.js @@ -0,0 +1,135 @@ +// 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(); +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/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/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); |
