diff options
Diffstat (limited to 'tools')
44 files changed, 1829 insertions, 40 deletions
diff --git a/tools/amber-dm-upgrade-test.js b/tools/amber-dm-upgrade-test.js index 9a5070a..250e8ef 100644 --- a/tools/amber-dm-upgrade-test.js +++ b/tools/amber-dm-upgrade-test.js @@ -120,14 +120,14 @@ try { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Messages"]')?.click()); console.log('\n[signed in with Amber by intent]'); - check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect Amber'))); + check('Messages says messages sent to you can\'t be opened, and offers Connect Amber', await waitText('can’t be opened here', 10000) && await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Connect signer'))); await sleep(4000); check('no decrypt intent went to Amber for an arriving wrap', !(await page.evaluate(() => window.__amberUrls.some((u) => /nip44_decrypt/.test(u))))); check('neither message shows', !(await text()).includes('a message from before the upgrade') && !(await text()).includes('wrote off')); if (!ALL) check('the arriving wrap is not logged as opened', await page.evaluate(([k, id]) => !(JSON.parse(localStorage.getItem(k + ':messages') || '{}').wraps || []).includes(id.slice(0, 16)), [base, NEW.id])); console.log('\n[Connect Amber: the same account over NIP-46]'); - await click('connect amber'); await sleep(300); + await click('connect signer'); await sleep(300); check('a screen explains it and offers the signer-app link', await waitText('read your messages', 5000)); let uri = null; for (let i = 0; i < 40 && !uri; i++) { uri = await page.evaluate(() => document.querySelector('a[href^="nostrconnect://"]')?.getAttribute('href')); if (!uri) await sleep(250); } diff --git a/tools/amber-login-test.js b/tools/amber-login-test.js index 9da49a8..51f0991 100644 --- a/tools/amber-login-test.js +++ b/tools/amber-login-test.js @@ -41,8 +41,8 @@ try { console.log('\n[the Amber door]'); check('the front door offers Nostr sign-in', await click('sign in with nostr') || await click('nostr')); await sleep(500); - check('on Android, the login offers Amber', await waitText('sign in with amber', 5000)); - await click('sign in with amber'); await sleep(400); + check('on Android, the login offers Amber', await waitText('sign in with a signer app', 5000)); + await click('sign in with a signer app'); await sleep(400); check('tapping it opens a get_public_key intent', (await lastType()) === 'get_public_key', JSON.stringify(await urls())); check('...and shows the waiting card with a paste box', await waitText('waiting for your signer', 2000) && !!(await page.$('.amber-paste'))); await deliver(npubEncode(PK)); diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js index 214ab45..68e7191 100644 --- a/tools/ark/custody-test.js +++ b/tools/ark/custody-test.js @@ -127,4 +127,25 @@ const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expi console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened'); } +// Lightning sends: a wallet that renews its own coins spends the nearest +// expiry first; the custodial default keeps the smallest coin that covers it +{ + const m = mk(true); + m.info.lnSendFees = { baseFeeSat: 0, minFeeSat: 0, ppmExpiryTable: [] }; + m.state.vtxos = [coin('young', 3000, 2000), coin('old', 9000, 1100), coin('older', 2000, 1050), coin('past', 8000, 900)]; + const ids = (amt) => m._selectLnInputs(m.state.vtxos, 1000, amt, 0).inputs.map((v) => v.id); + assert.deepEqual(ids(2500), ['young'], 'custodial: the smallest coin that covers it'); + assert.deepEqual(ids(12000), ['old', 'young'], 'custodial: largest first'); + m.lnOldestFirst = () => true; + assert.deepEqual(ids(1500), ['older'], 'self: the nearest expiry that covers it'); + assert.deepEqual(ids(2500), ['old'], '…skipping one too small'); + assert.deepEqual(ids(10000), ['older', 'old'], 'self: gathers nearest expiry first'); + assert.deepEqual(ids(13000), ['older', 'old', 'young'], 'coins inside their timelock carry it alone when they can'); + // a pile of old crumbs that would breach the input cap: largest-first instead + m.state.vtxos = [...Array.from({ length: MAX_SEND_INPUTS + 2 }, (_, i) => coin('crumb' + i, 100, 1010)), coin('big', 6000, 3000)]; + assert.deepEqual(ids(6050).slice(0, 1), ['big'], 'too many old crumbs for one payment: largest first'); + assert.equal(ids(6050).length, 2); + console.log('✓ Lightning sends: nearest expiry first when renewing your own coins, smallest-that-covers otherwise'); +} + console.log('\n✅ custody tiers behave'); diff --git a/tools/ark/refresh-test.js b/tools/ark/refresh-test.js index 0557659..6938daf 100644 --- a/tools/ark/refresh-test.js +++ b/tools/ark/refresh-test.js @@ -29,6 +29,7 @@ import { import { ALICE, aliceReady } from './alice.js'; const ARK = process.env.ARK_URL || 'http://127.0.0.1:3535'; +const ESPLORA = process.env.ESPLORA_URL || 'http://127.0.0.1:30002'; const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`; const FUND_SAT = 12345; const SPEND_SAT = 3000; @@ -86,7 +87,13 @@ if (!status?.fundingTx) { console.error('round never executed'); process.exit(1) const fundingTx = parseTx(status.fundingTx); console.log(`round executed: funding tx ${fundingTx.txid}, ${status.outputVtxos.length} output vtxo(s)`); execSync(`${BCLI} generatetoaddress 2 $(${BCLI} getnewaddress) >/dev/null`, { shell: '/bin/bash' }); -await sleep(2000); +// alice's bark checks the round anchor against esplora, which indexes a block +// ~5 s after it's mined; hand her the coin before then and she drops it +for (let i = 0; i < 30; i++) { + const st = await fetch(`${ESPLORA}/tx/${fundingTx.txid}/status`).then((r) => r.ok && r.json()).catch(() => null); + if (st?.confirmed) break; + await sleep(1000); +} const newVtxo = decodeVtxo(status.outputVtxos[0]); console.log(`new vtxo: ${newVtxo.id} (${newVtxo.amountSat} sat, expiry ${newVtxo.expiryHeight}, transitions: ${newVtxo.genesis.map((g) => g.transition.type).join(' -> ')})`); diff --git a/tools/blob-alt-test.js b/tools/blob-alt-test.js new file mode 100644 index 0000000..4d958f2 --- /dev/null +++ b/tools/blob-alt-test.js @@ -0,0 +1,92 @@ +// A face or banner whose host has lost the file is found on another server +// that holds the same Blossom blob (same sha256 name): the one the person +// publishes in their kind 10063 first. The small avatar's thumbnail is made +// from the copy, the profile page's big face and banner paint from it, and +// the find is remembered on the device. +// Run: bun tools/blob-alt-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { nip19 } from 'nostr-tools'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sk = generateSecretKey(), PK = getPublicKey(sk), now = Math.floor(Date.now() / 1000); +const H1 = 'c11a29785c1af623a45ebfdb23860170a9d0c0ead31c1bcb209085cd662f7a17'; +const H2 = '479711427c577f040a5bf8cb05c47f75abfef37580893dd63b66f777c34dc327'; +const png = (color) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', `color=c=${color}:size=320x320`, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const FACE = png('orange'), COVER = png('teal'); +const html = await buildHtml({ minify: true, pwa: false }); +const hits = { dead: 0, alt: 0 }; +let origin = ''; +const server = Bun.serve({ port: 0, fetch: (req) => { + const u = new URL(req.url); + if (u.pathname.startsWith('/dead/')) { hits.dead++; return new Response('{"error":"The requested blob was not found."}', { status: 404, headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' } }); } + if (u.pathname === `/alt/${H1}.png`) { hits.alt++; return new Response(FACE, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + if (u.pathname === `/alt/${H2}.png`) { hits.alt++; return new Response(COVER, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + if (u.pathname === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } }); + return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }); +} }); +origin = server.url.origin; +const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [], content: `Post ${i}: the harbour at dusk, boats coming in.` }, sk)); +const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Karnage', picture: `${origin}/dead/${H1}.png`, banner: `${origin}/dead/${H2}.png`, about: 'Saying the quiet part out loud.' }) }, sk); +const servers = finalizeEvent({ kind: 10063, created_at: now, tags: [['server', `${origin}/alt/`]], content: '' }, sk); +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(origin) ? r.continue() : r.abort()); // the public hosts: unreachable here + await page.evaluateOnNewDocument(([notes, profile, servers]) => { + 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 = []; + 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)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100); + if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile]; + if (f.kinds?.includes(10063) && f.authors?.includes(servers.pubkey)) evs = [servers]; + if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id)); + 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; + }, [notes, profile, servers]); + const bg = (sel) => page.evaluate((s) => document.querySelector(s)?.style.backgroundImage || '', sel); + // the feed: the small face's thumbnail is made from the copy + await page.goto(origin + '/feed', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 }); + // the copy is found and the thumbnail made from it (the probe and the + // thumbnail's own fetch: two hits); the row keeps the look it was admitted + // with, the next paint wears the face + for (let i = 0; i < 80 && hits.alt < 2; i++) await sleep(250); + assert.ok(hits.dead >= 1 && hits.alt >= 2, `the dead host was asked (${hits.dead}) and the copy fetched for the thumbnail (${hits.alt})`); + console.log('✓ the thumbnail is made from the copy on the published server'); + const remembered = await page.evaluate(() => localStorage.getItem('btc-wallet-blob-alts') || ''); + assert.ok(remembered.includes(`/alt/${H1}.png`), 'the find is remembered on the device'); + // the profile page (the screen that showed an empty circle and no cover): + // big face and banner from the copies, the post rows' small faces from + // the thumbnail + await page.goto(origin + '/' + nip19.npubEncode(PK), { waitUntil: 'domcontentloaded' }); + await page.waitForFunction((h) => (document.querySelector('.profile-banner')?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, H2) + .catch(async () => { throw new Error('banner not from the copy: ' + await bg('.profile-banner')); }); + const face = await bg('.profile-avatar'); + assert.ok(face.includes(`/alt/${H1}.png`), 'the big face paints from the copy: ' + face); + console.log('✓ the profile page paints the big face and the banner from the copies'); + const rowSel = `.note-post[data-zap-post="${notes[0].id}"] .ava-img`; + await page.waitForFunction((s, h) => /^url\("data:/.test(document.querySelector(s)?.style.backgroundImage || '') || (document.querySelector(s)?.style.backgroundImage || '').includes('/alt/' + h), { timeout: 20000 }, rowSel, H1) + .catch(async () => { throw new Error('post row face: ' + await page.evaluate((id) => [...document.querySelector('.note-post[data-zap-post="' + id + '"]')?.querySelectorAll('[class*=avatar], .ava-img') || []].map((e) => e.outerHTML.slice(0, 220)).join(' || '), notes[0].id)); }); + assert.deepEqual(errors, []); + console.log('✓ the post rows wear the face, and no page errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/feed-boot-chrome-test.js b/tools/feed-boot-chrome-test.js new file mode 100644 index 0000000..91e5188 --- /dev/null +++ b/tools/feed-boot-chrome-test.js @@ -0,0 +1,113 @@ +// The feed opened by URL (/feed) paints as the restoring wallet's from its +// first frame. It painted as a visitor's — no post button, chips or nav — +// until the restore landed a beat later (the vault's scrypt, the wallet's +// state, every feature's init: ~0.5 s on a laptop, more on a phone), and +// the button "took a while to appear" (Adam, 2026-10-31). Boot now tells +// a wallet is about to open (a session list in the tab, a vault the last +// unlock opened without a password, watch-only accounts) before the work, +// and the page paints for it. A visitor with no wallet still gets none. +// Run: bun tools/feed-boot-chrome-test.js +import puppeteer from './puppeteer.js'; +import { buildHtml } from '../build.js'; +import { encryptVault } from '../src/wallet.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; + +let fails = 0; +const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); if (!ok) fails++; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5331, fetch: (req) => { + const p = new URL(req.url).pathname; + if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); + 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 mnemonic = generateMnemonic(wordlist); +const account = { id: 'boot-test', label: 'Boot', type: 'full', mnemonic, passphrase: '', network: 'regtest', persisted: true }; +const vault = encryptVault([{ label: account.label, mnemonic, passphrase: '', network: 'regtest' }], ''); + +// Every mutation batch gets a number; the first batch each piece of the page +// showed up in says whether it came with the page or after it. +const watch = () => { + window.__seen = {}; window.__batch = 0; + const SEL = { page: '.feed-page', fab: '.feed-post-fab', chips: '.feed-chips', nav: '.app-bottom-nav', side: '.wallet-side' }; + new MutationObserver(() => { + window.__batch++; + for (const [k, s] of Object.entries(SEL)) if (!(k in window.__seen) && document.querySelector(s)) { window.__seen[k] = { batch: window.__batch, at: Math.round(performance.now()) }; if (k === 'fab') window.__fab0 = document.querySelector(s); } + }).observe(document, { childList: true, subtree: true }); +}; +async function load(name, seed) { + const ctx = await browser.createBrowserContext(); // its own storage: each scenario starts clean + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844 }); + await page.evaluateOnNewDocument(watch); + await page.evaluateOnNewDocument(seed, { account, vault }); + await page.goto('http://localhost:5331/feed', { waitUntil: 'domcontentloaded' }); + await sleep(3500); + const r = await page.evaluate(() => ({ + seen: window.__seen, errors: [], + fabSame: !!window.__fab0 && document.querySelector('.feed-post-fab') === window.__fab0, + chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent.trim() + (c.classList.contains('on') ? '*' : '')), + fab: !!document.querySelector('.feed-post-fab'), nav: !!document.querySelector('.app-bottom-nav'), + walletOpen: !!document.querySelector('.app-nav-profile, .chat-avatar'), + blankNote: localStorage.getItem('btc-wallet-vault-blank'), + text: document.body.innerText.replace(/\s+/g, ' ').slice(0, 120), + })); + r.errors = errors; + console.log(`[${name}] ${JSON.stringify(r.seen)} chips=${r.chips.join('|')}`); + await ctx.close(); + return r; +} +// with the page, or (the nav rides the boot shell too) before it +const withPage = (r, k) => r.seen[k] && r.seen.page && r.seen[k].batch <= r.seen.page.batch; + +try { + // 1. A reload: the tab's session list says which wallet is coming. + let r = await load('session', ({ account }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id })); + }); + check('session: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen)); + check('session: the chips paint with the page', withPage(r, 'chips')); + check('session: the nav paints with the page', withPage(r, 'nav')); + check('session: the wallet then opens behind it', r.fab && r.nav && r.walletOpen, r.text); + check('session: the button is the same node through the restore (no blink)', r.fabSame); + check('session: Popular stood for the visitor copy, then the wallet\'s own chips', r.chips.length >= 2 && r.chips.filter((c) => /Popular/.test(c)).length === 1, r.chips.join('|')); + check('session: no page errors', r.errors.length === 0, r.errors.join(' | ')); + + // 2. A cold start: a vault the last unlock opened without a password. + r = await load('vault', ({ vault }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-vault', JSON.stringify(vault)); + localStorage.setItem('btc-wallet-vault-blank', '1'); + }); + check('vault: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen)); + check('vault: the nav paints with the page', withPage(r, 'nav')); + check('vault: the wallet opens behind it', r.fab && r.walletOpen, r.text); + check('vault: the same node through the scrypt restore', r.fabSame); + check('vault: no page errors', r.errors.length === 0, r.errors.join(' | ')); + + // 3. The same vault before any unlock left its note: boot can't tell, so + // the page paints as a visitor's and grows the button when the wallet + // opens — and the unlock leaves the note for next time. + r = await load('vault, no note', ({ vault }) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + localStorage.setItem('btc-wallet-vault', JSON.stringify(vault)); + }); + check('unnoted vault: the button still comes, after the page', r.fab && r.seen.fab && r.seen.page && r.seen.fab.batch > r.seen.page.batch, JSON.stringify(r.seen)); + check('unnoted vault: the unlock leaves the note', r.blankNote === '1'); + + // 4. A visitor: no wallet on this device, nothing to paint for. + r = await load('visitor', () => { localStorage.setItem('btc-wallet-network', 'regtest'); }); + check('visitor: the feed paints, without a post button', r.seen.page && !r.fab && !r.seen.fab, JSON.stringify(r.seen)); + check('visitor: no page errors', r.errors.length === 0, r.errors.join(' | ')); +} finally { + await browser.close(); + server.stop(true); +} +console.log(fails ? `\n❌ ${fails} failing` : '\n✅ the feed paints for the wallet that is coming'); +process.exit(fails ? 1 : 0); diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js index ea466fd..ccba776 100644 --- a/tools/feed-bottom-nav-test.js +++ b/tools/feed-bottom-nav-test.js @@ -13,7 +13,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { ui, wallet, render, get feature() { return feature; }, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; diff --git a/tools/feed-fab-anim-test.js b/tools/feed-fab-anim-test.js new file mode 100644 index 0000000..bd9379c --- /dev/null +++ b/tools/feed-fab-anim-test.js @@ -0,0 +1,58 @@ +// The post button is on screen the moment the feed is, while the page is +// still sliding in. It is position: fixed, and it used to be rendered inside +// the page — whose entrance animation puts a transform on it, which makes +// fixed descendants position within the page: the button rode at the foot +// of the feed card, below the screen, until the animation ended (measured +// on a phone: 4109 px down at 87 ms, in place at 562 ms). Now the shell +// paints it beside the page. +// Run: bun tools/feed-fab-anim-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const p = new URL(req.url).pathname; + if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); + 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 account = { id: 'fab-test', label: 'Fab', type: 'full', mnemonic: generateMnemonic(wordlist), passphrase: '', network: 'regtest', persisted: true }; +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.evaluateOnNewDocument((account) => { + localStorage.setItem('btc-wallet-network', 'regtest'); + sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id })); + }, account); + await page.goto(server.url.origin + '/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.app-bottom-nav', { timeout: 20000 }); + await sleep(1500); + const tap = (which) => page.evaluate((which) => new Promise((resolve) => { + const b = [...document.querySelectorAll('.app-bottom-nav button')].find((x) => new RegExp(which, 'i').test(x.textContent)); + const t0 = performance.now(); const samples = []; + const tick = () => { + const f = document.querySelector('.feed-post-fab'); const ms = Math.round(performance.now() - t0); + if (f) { const r = f.getBoundingClientRect(); samples.push({ ms, top: Math.round(r.top), right: Math.round(innerWidth - r.right), anim: !!document.querySelector('.anim-page'), inPage: !!f.closest('.anim-page, .feed-page') }); } + if (ms < 320) requestAnimationFrame(tick); else resolve(samples); + }; + b.click(); requestAnimationFrame(tick); + }), which); + for (const round of [1, 2]) { + await tap('wallet'); await sleep(900); + const s = await tap('feed'); + assert.ok(s.length >= 5, 'the button was sampled while the page slid in: ' + s.length); + const first = s[0]; + assert.ok(first.anim, 'the page was still animating at the first sample (' + first.ms + ' ms)'); + assert.ok(!first.inPage, 'the button is painted beside the page, not inside it'); + const off = s.filter((x) => x.top > 844 - 40 || x.top < 0); + assert.equal(off.length, 0, `the button sat off screen in ${off.length} of ${s.length} frames: ` + JSON.stringify(s.slice(0, 4))); + console.log(`✓ round ${round}: the post button is on screen from the first frame (top ${first.top}px at ${first.ms} ms, ${s.length} frames sampled)`); + } + assert.deepEqual(errors, []); + console.log('✓ no page errors'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js index 59d6f7c..d66ab59 100644 --- a/tools/feed-new-posts-test.js +++ b/tools/feed-new-posts-test.js @@ -14,7 +14,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => k === 'follows' ? { tags: [['p', author]], at: Date.now()/1000 } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView() || h('div', { class: 'wallet-screen' }, 'Wallet'), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: name => name === 'unreadMessages' ? 1 : null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { ui, wallet, render, get feature() { return feature; }, async feed() { ui.chatOpen = true; ui.msgView = 'feed'; ui.noteThread = null; diff --git a/tools/feed-streaming-test.js b/tools/feed-streaming-test.js index c69b1eb..4fa00cf 100644 --- a/tools/feed-streaming-test.js +++ b/tools/feed-streaming-test.js @@ -26,7 +26,7 @@ const wallet = { xpub: 'test-wallet', loaded: true, nostrRelays: () => ['wss://k : k === 'feedNotes' ? seeded : k === 'profiles' ? { [author]: { name: 'Stream Author', t: Date.now() } } : d, saveFeatureState() {}, registerCacheExtension() {} }; let feature; -function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), feature.bottomNav()]); } +function render() { if (feature) morphChildren(document.querySelector('#app'), [feature.screenView(), ...(feature.pageFloaters ? feature.pageFloaters() : []), feature.bottomNav()]); } feature = messagesFeature({ h, ui, wallet, render, toast() {}, hook: () => null, brandHeader: () => h('div', { style: 'height:60px' }, 'Coinos') }); window.test = { feature, render }; diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js index cccde41..61119a0 100644 --- a/tools/feed-video-pager-test.js +++ b/tools/feed-video-pager-test.js @@ -67,6 +67,8 @@ const openClip = async (word) => { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); await sleep(400); const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); }; diff --git a/tools/fullscreen-notice-test.js b/tools/fullscreen-notice-test.js index cf15f8c..d3b2785 100644 --- a/tools/fullscreen-notice-test.js +++ b/tools/fullscreen-notice-test.js @@ -68,6 +68,8 @@ async function run(lockOk) { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); await sleep(400); const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); }; diff --git a/tools/gift-from-test.js b/tools/gift-from-test.js new file mode 100644 index 0000000..a0ffaf8 --- /dev/null +++ b/tools/gift-from-test.js @@ -0,0 +1,71 @@ +// A gift link can name its sender: #f=<base32 pubkey|created_at|sig>, a +// nostr signature over the gift's code. The claim screen shows who it's +// from (and that claiming follows them); a link whose signature doesn't +// match its code — passed on as someone else's gift — shows no sender. +// Run: bun run build && bun tools/gift-from-test.js +import puppeteer from './puppeteer.js'; +import { base32nopad } from '@scure/base'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +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 server = Bun.serve({ port: 0, async fetch(req) { + const p = new URL(req.url).pathname; + const f = Bun.file('dist' + (p === '/' ? '/index.html' : p)); + return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } }); +} }); +const giftCode = () => { + const b = new Uint8Array(42); + b[0] = 0x11; b[1] = 3; // ark gift, mutinynet + new DataView(b.buffer).setBigUint64(2, 1000n, true); + b.set(crypto.getRandomValues(new Uint8Array(32)), 10); + return base32nopad.encode(b); +}; +const sk = generateSecretKey(); +const fromFrag = (code) => { + const ev = finalizeEvent({ kind: 27121, created_at: Math.floor(Date.now() / 1000), content: '', + tags: [['x', bytesToHex(sha256(new TextEncoder().encode(code.toUpperCase())))]] }, sk); + const b = new Uint8Array(100); + b.set(hexToBytes(ev.pubkey), 0); + new DataView(b.buffer).setUint32(32, ev.created_at); + b.set(hexToBytes(ev.sig), 36); + return base32nopad.encode(b); +}; +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const text = (page) => page.evaluate(() => document.body.innerText); +const waitText = async (page, re, ms = 30000) => { + for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); } + return false; +}; +async function open(path) { + const page = await browser.newPage(); + await page.goto(server.url.href); + await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); }); + await page.goto(server.url.href + path); + await waitText(page, /aren.t visible/i); + await sleep(500); + return page; +} +try { + const code = giftCode(); + let page = await open(`ag/${code}#m=hi&f=${fromFrag(code)}`); + let tx = await text(page); + check('a signed link names its sender', /\bFrom\b/.test(tx) && /Claiming it follows them/.test(tx), tx.slice(0, 300).replace(/\n/g, ' | ')); + check('the note still shows', /\bhi\b/.test(tx)); + await page.close(); + // an upper-case key, as a scanner may hand it over + page = await open(`ag/${code}#F=${fromFrag(code)}`); + { const tx = await text(page); check("an upper-cased link still names its sender", /Claiming it follows them/.test(tx), tx.slice(0, 200).replace(/\n/g, " | ")); } + await page.close(); + // the same signature on a different gift: no sender + page = await open(`ag/${giftCode()}#f=${fromFrag(code)}`); + check('a signature over another code names nobody', !/Claiming it follows them/.test(await text(page))); + await page.close(); +} finally { + await browser.close(); + server.stop(); +} +console.log(ok ? 'PASS' : 'FAIL'); +process.exit(ok ? 0 : 1); diff --git a/tools/gift-profile-setup-test.js b/tools/gift-profile-setup-test.js new file mode 100644 index 0000000..9304d1a --- /dev/null +++ b/tools/gift-profile-setup-test.js @@ -0,0 +1,96 @@ +// After a gift is opened into a fresh wallet and its seed written down, the +// first-run wizard asks for a username, then a picture and a cover — the gift's +// recipient should arrive as someone people can pay by name. "Not now" lands in +// the account the gift went to. +// Drives an ark gift on mutinynet whose bearer key holds nothing (a random +// secret: the staging ASP sees no coin, so the sweep finds it taken), then +// "Create wallet anyway" → the seed → on to the wizard. +// Run: bun run build && bun tools/gift-profile-setup-test.js [screenshot.png] +import puppeteer from './puppeteer.js'; +import { base32nopad } from '@scure/base'; +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 server = Bun.serve({ port: 0, async fetch(req) { + const p = new URL(req.url).pathname; + const f = Bun.file('dist' + (p === '/' ? '/index.html' : p)); + return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } }); +} }); +const giftCode = () => { + const b = new Uint8Array(42); + b[0] = 0x11; b[1] = 3; // ark gift, mutinynet + new DataView(b.buffer).setBigUint64(2, 1000n, true); + b.set(crypto.getRandomValues(new Uint8Array(32)), 10); + return base32nopad.encode(b); +}; +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const text = (page) => page.evaluate(() => document.body.innerText); +const click = (page, x) => page.evaluate((x) => { + const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); + e?.click(); return !!e; +}, x); +const waitText = async (page, re, ms = 30000) => { + for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); } + return false; +}; +async function toSeed(page, note) { + await page.goto(server.url.href); + await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); }); + await page.goto(server.url.href + 'ag/' + giftCode() + (note ? '#m=' + encodeURIComponent(note) : '')); + check('the gift opens: the server sees no coin behind it', await waitText(page, /aren.t visible/i)); + if (note) { + const q = await page.evaluate(() => document.querySelector('.gift-note')?.textContent || null); + check('the sender\'s note shows on the gift', q === note, String(q)); + check('...and leaves the address bar with the code', await page.evaluate(() => location.pathname === '/' && !location.hash)); + } + await click(page, 'claim'); + check('...so the claim finds it taken', await waitText(page, /create a wallet anyway/i)); + check('...create a wallet anyway shows the seed', await click(page, 'create a wallet anyway') && await waitText(page, /recovery phrase/i, 10000)); +} +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); + await toSeed(page, 'Happy birthday! 🎂 <b>not bold</b>'); + await click(page, 'skip verification'); + check('the seed done: the wizard asks for a username', await waitText(page, /now pick a username/i, 10000), (await text(page)).slice(0, 200)); + check('...with the claim form (or its wait) and Not now', await page.evaluate(() => !!document.querySelector('.onb')) && /not now/i.test(await text(page))); + if (process.argv[2]) await page.screenshot({ path: process.argv[2] }); + // the look step follows the name, as in any first run + await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar')); + await page.reload(); + check('the next step is the picture and cover', !!await page.waitForSelector('.onb-cover', { timeout: 15000 }).catch(() => null)); + // a second gift, verified this time, then Not now + const p2 = await browser.newPage(); + p2.on('pageerror', (e) => errors.push(String(e))); + await p2.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 }); + await toSeed(p2); + check('Verify backup opens the word check', await click(p2, 'verify') && await waitText(p2, /word #?\d/i, 5000)); + await sleep(500); + // the verify page doesn't show the words: read them from the account + const filled = await p2.evaluate(() => { + const accs = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'); + const list = Array.isArray(accs) ? accs : accs.accounts || accs.list || []; + const m = list.map((a) => a.mnemonic).filter(Boolean).pop(); if (!m) return false; + const w = m.split(' '); + for (const lab of document.querySelectorAll('label.field')) { + const n = Number(lab.querySelector('.lab').textContent.match(/\d+/)[0]); + const inp = lab.querySelector('input'); + const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + set.call(inp, w[n - 1]); inp.dispatchEvent(new Event('input', { bubbles: true })); + } + return true; + }); + if (filled) { + await click(p2, 'open wallet'); + check('a verified seed leads to the username step too', await waitText(p2, /now pick a username/i, 10000)); + await click(p2, 'not now'); + let gone = false; + for (let i = 0; i < 20 && !gone; i++) { await sleep(250); gone = !(await p2.evaluate(() => !!document.querySelector('.onb'))); } + check('Not now: into the wallet, the wizard gone', gone, (await text(p2)).slice(0, 200)); + } else console.log(' (accounts not in localStorage — verify path not driven)'); + check('no page errors', errors.length === 0, errors.join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a gift\'s new wallet is offered a name, a picture and a cover' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/kind0-overwrite-test.js b/tools/kind0-overwrite-test.js new file mode 100644 index 0000000..e791560 --- /dev/null +++ b/tools/kind0-overwrite-test.js @@ -0,0 +1,141 @@ +// A profile (kind 0) must never be written over one we failed to find. +// +// 2026-10-30: a long-time nostr user signed in to coinos with his key for +// the first time. His kind 0 lived on relays that keep history (ditto, +// snort, oxtr) and nowhere our quick lookup asked, so the payment-address +// publish merged over an empty base and went out as {lud16, nip05} alone: +// name, picture and banner gone from every relay. These scenarios run the +// messages feature against a fake relay network where only an archive holds +// the real profile, and record every event the wallet publishes. +// Run: bun tools/kind0-overwrite-test.js +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; + +let fails = 0; +const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); + if (!ok) fails++; +}; + +globalThis.localStorage = { + _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); }, + removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; }, + get length() { return Object.keys(this._d).length; }, +}; + +// per-relay stores: REQ answered from the socket's own relay, EVENT stored there +const relayStore = {}; +const published = []; // { url, ev } +const reqs = []; // { url, filter } +const norm = (u) => String(u).replace(/\/$/, ''); +class FakeSocket { + constructor(url) { + this.url = norm(url); this.readyState = 0; + setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0); + } + reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); } + send(str) { + let m; try { m = JSON.parse(str); } catch { return; } + if (m[0] === 'EVENT') { (relayStore[this.url] ||= []).push(m[1]); published.push({ url: this.url, ev: m[1] }); this.reply(['OK', m[1].id, true, '']); } + if (m[0] === 'REQ') { + const [, sub, ...filters] = m; + for (const f of filters) reqs.push({ url: this.url, filter: f }); + if (filters.some((f) => f.cache)) { this.reply(['EOSE', sub]); return; } // Primal's cache knows nothing here + const hit = (relayStore[this.url] || []).filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)))); + hit.sort((a, b) => b.created_at - a.created_at); + for (const e of hit) this.reply(['EVENT', sub, e]); + this.reply(['EOSE', sub]); + } + } + close() { this.readyState = 3; this.onclose && this.onclose({}); } +} +globalThis.WebSocket = FakeSocket; +globalThis.Image = class { set src(v) { this._src = v; } }; +globalThis.fetch = async () => { throw new Error('offline'); }; + +const { messagesFeature } = await import('../src/features/messages.js'); + +const ARCHIVE = 'wss://relay.ditto.pub'; +const makeFeature = (sk, ui = {}) => { + const pk = getPublicKey(sk); + const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) }; + const walletSk = generateSecretKey(); + const wallet = { + mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [], nostrRelays: () => [], + nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) }, + loadFeatureState: () => null, saveFeatureState: () => {}, + _cacheKey: () => 'k0-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {}, + }; + const toasts = []; + const feature = messagesFeature({ + h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui, render: () => {}, wallet, toast: (m) => toasts.push(m), brandHeader: () => null, + hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null), + }); + return { feature, pk, toasts }; +}; +const newestOn = (url, pk) => (relayStore[url] || []).filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0]; +const kind0s = () => published.filter((p) => p.ev.kind === 0); + +console.log('[a first sign-in: the real profile lives only on an archive relay]'); +{ + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const old = { name: 'Nik kusanagi', display_name: 'Nik', lud16: '[email protected]', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' }; + relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)]; + const { feature } = makeFeature(sk); + // the payment-address publish that wiped him (lud16/nip05 empty or at the old address → repoint) + await feature.addressRenamed(null, '[email protected]'); + const sent = [...new Set(kind0s().map((p) => p.ev.id))]; + check('one kind 0 went out', sent.length === 1, sent.length); + const c = sent.length ? JSON.parse(kind0s()[kind0s().length - 1].ev.content) : {}; + check('it fills the empty nip05 and leaves the lightning address he had', c.nip05 === '[email protected]' && c.lud16 === '[email protected]', JSON.stringify(c)); + check('...and carries the name, face and cover the archive held', c.name === 'Nik kusanagi' && /face/.test(c.picture) && /cover/.test(c.banner), JSON.stringify(c)); + check('the archive\'s copy was asked for', reqs.some((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk))); + + // the onboarding wizard's suggestion must not rename him either + published.length = 0; + await feature.publishProfile({ name: 'satoshi1234', picture: 'https://punk/1.png' }, { fillOnly: ['name', 'picture'] }); + check('a fill-only suggestion changes nothing on a profile that has a name', kind0s().length === 0, kind0s().length); +} + +console.log('[a key with no profile anywhere: one deep look, then no waiting]'); +{ + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const { feature } = makeFeature(sk); + published.length = 0; reqs.length = 0; + const t0 = Date.now(); + await feature.publishProfile({ name: 'newbie' }); + const deep1 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length; + await feature.publishProfile({ about: 'hello' }); + const deep2 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length; + const last = JSON.parse(kind0s().pop().ev.content); + check('the new key still gets its profile', last.name === 'newbie' && last.about === 'hello', JSON.stringify(last)); + check('the archive was asked once, not again for the second publish', deep1 === 1 && deep2 === 1, `${deep1} ${deep2} in ${Date.now() - t0} ms`); +} + +console.log('[already hit: the editor finds the older copy and fills what is empty]'); +{ + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const old = { name: 'Nik kusanagi', display_name: 'Nik', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' }; + relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)]; + // what the wipe left: the address and a picture he re-uploaded by hand + const wiped = { lud16: '[email protected]', nip05: '[email protected]', picture: 'https://image.nostr.build/new.png' }; + for (const u of ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://purplepag.es', ARCHIVE]) (relayStore[u] ||= []).push(finalizeEvent({ kind: 0, created_at: 1793399187, tags: [], content: JSON.stringify(wiped) }, sk)); + const ui = { profEdit: { uname: 'nicstazzema', name: '', about: '', picture: wiped.picture, banner: '' } }; + const { feature, toasts } = makeFeature(sk, ui); + const versions = await feature.profileHistory(); + check('both copies found', versions.length === 2, versions.map((e) => e.created_at).join(' ')); + const older = versions.find((e) => /Nik/.test(e.content)); + const n = feature.fillProfileFrom(older); + check('name and banner filled, the picture he chose kept', n === 2 && ui.profEdit.name === 'Nik' && ui.profEdit.banner === old.banner && ui.profEdit.picture === wiped.picture, JSON.stringify(ui.profEdit)); + check('says what it filled', toasts.some((m) => /Filled 2 fields/.test(m)), toasts.join(' | ')); + published.length = 0; + await feature.saveProfileDraft(ui.profEdit); + const saved = JSON.parse(kind0s().pop().ev.content); + check('Save publishes the name back with the address intact', saved.name === 'Nik' && saved.display_name === 'Nik' && saved.lud16 === wiped.lud16 && /new\.png/.test(saved.picture) && /cover/.test(saved.banner), JSON.stringify(saved)); + check('newer than the wiped copy everywhere', newestOn('wss://relay.coinos.io', pk).created_at > 1793399187); +} + +console.log(fails ? `\n❌ ${fails} failing` : '\n✅ profiles are never written over unseen'); +process.exit(fails ? 1 : 0); diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js new file mode 100644 index 0000000..18a1943 --- /dev/null +++ b/tools/ln-pay-double-wake.test.js @@ -0,0 +1,74 @@ +// Two wakes of the phone's worker, seconds apart, each over its own copy of +// the wallet, both drive one Lightning send (PullCap, 2026-10-24 23:21). The +// second's InitiateLightningPayment is refused — the HTLC is already 'ln-spent' +// to this very payment — and that once marked a PAID send 'revoking'. Now the +// send follows the payment instead, and a send in 'revoking' asks the ASP +// before revoking: paid → settled; still in flight → back to waiting. +// Run: bun test tools/ln-pay-double-wake.test.js +import { test, expect, mock } from 'bun:test'; +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import * as realLn from '../src/ark/lightning.js'; +import * as realProto from '../src/ark/proto.js'; +import * as realSend from '../src/ark/send.js'; + +const PRE = 'ab'.repeat(32); +const HASH = hex.encode(sha256(hex.decode(PRE))); +const asp = { initiate: null, check: null, revoked: 0, checks: 0 }; +mock.module('../src/ark/lightning.js', () => ({ + ...realLn, + initiateLightningPayment: async () => { if (asp.initiate) throw asp.initiate; }, + checkLightningPayment: async () => { asp.checks++; if (asp.check instanceof Error) throw asp.check; return asp.check; }, + requestLightningPayHtlcRevocation: async () => { asp.revoked++; throw new Error('revocation should not be asked for'); }, +})); +mock.module('../src/ark/proto.js', () => ({ ...realProto, decodeVtxo: () => ({ id: 'htlc1', point: { raw: new Uint8Array(36) } }) })); +mock.module('../src/ark/send.js', () => ({ ...realSend, registerVtxoTransactions: async () => {}, + buildArkoorSend: () => ({}), genUserNonces: () => ({}), cosignPartBytes: () => new Uint8Array(1) })); +const { ArkManager } = await import('../src/ark/manager.js'); +const { GrpcError } = realProto; + +function mgrWith(step) { + const action = { id: 'lnpay-1700000000000', type: 'ln-pay', step, paymentHash: HASH, amountSat: 1, htlcExpiry: 1000, htlcBytesList: ['00'], htlcVtxoIds: ['htlc1'], changeBytesList: [], invoice: 'lnbc1' }; + const m = Object.create(ArkManager.prototype); + const own = { + arkUrl: 'http://ark.test', chain: { tipHeight: async () => 900 }, + state: { actions: [action], vtxos: [{ id: 'htlc1', state: 'pending', amountSat: 1 }], movements: [] }, + _save: () => {}, _lnDriving: new Set(), _spentRetried: new Set(), + _key: () => ({ pubkey: new Uint8Array(33) }), _mailboxKey: () => ({ pubkey: new Uint8Array(33) }), + }; + for (const [k, v] of Object.entries(own)) Object.defineProperty(m, k, { value: v, writable: true, configurable: true }); + return { m, action }; +} + +test("a second wake's initiate refused as 'ln-spent' follows the payment to done", async () => { + Object.assign(asp, { initiate: new GrpcError(3, 'bad user input: vtxo b47f…:0 is not spendable (state: ln-spent)', 'bark_server.ArkService/InitiateLightningPayment'), check: { status: 'success', preimage: PRE }, revoked: 0 }); + const { m, action } = mgrWith('cosigned'); + await m._driveLnPay(action); + expect(action.step).toBe('done'); + expect(action.preimage).toBe(PRE); + expect(asp.revoked).toBe(0); + expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1); +}); + +test("a send in 'revoking' that the ASP paid settles, nothing revoked", async () => { + Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action); + expect(action.step).toBe('done'); + expect(asp.revoked).toBe(0); +}); + +test("a send in 'revoking' still in flight before its expiry goes back to waiting", async () => { + Object.assign(asp, { initiate: null, check: { status: 'pending' }, revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action); + expect(action.step).toBe('initiated'); + expect(asp.revoked).toBe(0); +}); + +test("a send the ASP never saw (not found) is still revoked", async () => { + Object.assign(asp, { initiate: null, check: new GrpcError(5, 'not found: payment attempt not found', 'x'), revoked: 0 }); + const { m, action } = mgrWith('revoking'); + await m._driveLnPay(action).catch(() => {}); + expect(asp.revoked).toBe(1); +}); diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js index a188c5d..7b429c9 100644 --- a/tools/media-moments-feed-test.js +++ b/tools/media-moments-feed-test.js @@ -175,6 +175,18 @@ try { check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false)); check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline'))); await page.screenshot({ path: '/tmp/media-timeline-pager.png' }); + // back in the feed: the clip has a moment now, so the moments bar is its + // only seek bar (no player controls), with the middle button to play it + await page.keyboard.press('Escape'); await sleep(500); + await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.dispatchEvent(new Event('timeupdate')); }); + await sleep(200); + const one = await page.evaluate(() => { const v = document.querySelector('video.note-video'); const b = v.closest('.note-video-box'); const tl = b.querySelector('.media-timeline'); + return { controls: v.controls, own: b.classList.contains('own-bar'), bar: !!tl && getComputedStyle(tl).display !== 'none', mark: !!b.querySelector('.media-mark'), pp: getComputedStyle(b.querySelector('.vid-pp')).display }; }); + check('in the feed, a clip with a moment has one seek bar: the moments bar, no player controls', !one.controls && one.own && one.bar && one.mark, JSON.stringify(one)); + check('...and the middle button plays it, on a touch screen too', one.pp === 'flex', JSON.stringify(one)); + const vid2 = await page.evaluate(() => { const b = document.querySelector('.note-video-box .vid-pp').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; }); + await page.mouse.click(vid2.x, vid2.y); await sleep(600); + check('...which plays it in place', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager'))); check('no browser exceptions', errs.length === 0, errs.join(' | ')); await page.screenshot({ path: '/tmp/media-timeline-feed.png' }); } finally { await browser.close(); server.stop(); } diff --git a/tools/names-listening-test.js b/tools/names-listening-test.js new file mode 100644 index 0000000..acb84e2 --- /dev/null +++ b/tools/names-listening-test.js @@ -0,0 +1,70 @@ +// A wallet on another ark server tells the registrar it is listening once +// its offer listener is up, so Lightning receives queued for its name go +// out now instead of on the hourly retry. A wallet on the coinos server +// never does (it is paid while asleep). Run: bun tools/names-listening-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { namesFeature } from './src/features/names.js'; +import { setArkProviderId, getArkProviderId, getNetwork } from './src/api.js'; +${domHelper} +const state = { names: { byDomain: { 'coinos.io': { name: 'manna', domain: 'coinos.io' } } } }; +const ui = { screen: 'wallet' }; +const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => ({ ...e, id: 'ab'.repeat(32), sig: 'cd'.repeat(64), pubkey: 'aa'.repeat(32) }), nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' }; +const ctx = { h, ui, wallet, render: () => {}, toast: () => {}, copy: () => {}, copyBtn: () => null, brandHeader: () => null, goBack: () => {}, setAccount: () => {}, hook: (name) => (name === 'arkReady' ? true : null) }; +const feature = namesFeature(ctx); +window.test = { listening: () => feature.namesListening(), server: (id) => setArkProviderId(id), dbg: () => ({ net: getNetwork(), prov: getArkProviderId(), avail: feature.namesAvailable() }) }; +`; +const bundle = await Bun.build({ + entrypoints: ['names-listen-entry'], target: 'browser', + plugins: [{ name: 'names-listen', setup(build) { + build.onResolve({ filter: /^names-listen-entry$/ }, () => ({ path: 'entry', namespace: 'names-listen' })); + build.onLoad({ filter: /.*/, namespace: 'names-listen' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +// the registrar, faked: every call recorded, answered as told +const calls = []; +let answer = { status: 200, body: '{"waiting":1,"sat":52}' }; +await page.setRequestInterception(true); +page.on('request', (r) => { + if (!r.url().startsWith('https://names.coinos.io/')) return r.continue(); + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' }; + if (r.method() === 'OPTIONS') return r.respond({ status: 204, headers: cors }); + calls.push({ method: r.method(), path: new URL(r.url()).pathname, auth: r.headers().authorization || '' }); + r.respond({ ...answer, contentType: 'application/json', headers: cors }); +}); +const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +const listening = () => page.evaluate(() => test.listening()); +try { + await page.goto(server.url.href); + await page.waitForFunction(() => !!window.test); + check('a wallet on the coinos server says nothing', (await listening()) === null && !calls.length); + await page.evaluate(() => test.server('second')); + const r = await listening(); + const posts = () => calls.filter((c) => c.method === 'POST'); + check('on Second it tells the registrar it is listening', posts().length === 1 && posts()[0].path === '/listening', JSON.stringify(calls)); + check('...as itself (NIP-98)', /^Nostr /.test(posts()[0].auth)); + check('...and hears what is waiting', r && r.waiting === 1 && r.sat === 52, JSON.stringify(r)); + check('a second listen within ten minutes is not another call', (await listening()) === null && posts().length === 1); + await page.reload(); await page.waitForFunction(() => !!window.test); + answer = { status: 500, body: '{"error":"down"}' }; + const down = await listening(); + check('a registrar that is down is quietly left', down === null && posts().length === 2, JSON.stringify({ down, calls })); + answer = { status: 200, body: '{"waiting":0,"sat":0}' }; + check('...and asked again next time, not in ten minutes', (await listening())?.waiting === 0 && posts().length === 3); + check('no page errors', !errors.length, errors.join(' | ')); + console.log('\n✅ names: a wallet on another ark server says it is listening'); +} finally { + await browser.close(); + server.stop(true); +} diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 816b9ce..7e85ee1 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -47,11 +47,16 @@ function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) { function harness({ rec = baseRec(), recs = null, answered = false } = {}) { const published = []; + const declined = []; // wait replies left with the notifier (sent only if nobody answers) const fetchFn = async (url, opts) => { if (url.includes('/publish')) { published.push(JSON.parse(opts.body).event); return { ok: true, json: async () => ({ ok: true }) }; } + if (url.includes('/decline')) { + declined.push(JSON.parse(opts.body).event); + return { ok: true, json: async () => ({ ok: true, scheduled: true }) }; + } if (url.includes('/claim')) { return { ok: true, json: async () => ({ ok: true }) }; } @@ -73,7 +78,7 @@ function harness({ rec = baseRec(), recs = null, answered = false } = {}) { ? nip44.decrypt(ev.content, nip44.getConversationKey(clientSk, conn.servicePk)) : nip04.decrypt(clientSk, conn.servicePk, ev.content)); }; - return { deps, published, decryptReply }; + return { deps, published, declined, decryptReply }; } const ev = (e) => JSON.parse(JSON.stringify(e)); // strip verified symbol like a push payload would @@ -119,15 +124,37 @@ console.log('\n[merchant methods (BTCPay)]'); check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs)); } { - const { deps } = harness(); // a mirror without receive keys - check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false); + // PullCap 2026-10-24: lookups of a send in flight came back "not found". + // Every copy of the connection is asked; an outgoing send answers as one. + const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g'; + const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e'; + const fresher = baseRec(); fresher.updated = 2; fresher.ark = { ...fresher.ark, network: 'signet' }; + const live = baseRec(); live.updated = 1; + live.mgr.actions.push({ id: 'lnpay-1', type: 'ln-pay', step: 'done', paymentHash: H, preimage: 'cd'.repeat(32), amountSat: 21, invoice: INV }); + const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'old', rec: fresher }, { walletKey: 'w1', rec: live }] }); + await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps); + const r = decryptReply(published[0]).result || {}; + check('lookup_invoice finds a send in the older copy of the connection → outgoing, settled, its preimage', r.type === 'outgoing' && r.state === 'settled' && r.preimage === 'cd'.repeat(32), JSON.stringify(r)); + live.mgr.actions[0].step = 'initiated'; delete live.mgr.actions[0].preimage; + const t0 = Date.now(); + await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, { ...deps, deadline: Date.now() + 2500 }); + const r2 = decryptReply(published[1]).result || {}; + check('…one still in flight (ASP unreachable here) → outgoing, pending, inside the push budget', r2.type === 'outgoing' && r2.state === 'pending' && Date.now() - t0 < 4000, JSON.stringify(r2) + ` ${Date.now() - t0} ms`); +} +{ + const { deps, declined, decryptReply } = harness(); // a mirror without receive keys + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps); + const r = declined.length ? decryptReply(declined[0]) : null; + check('make_invoice on an old mirror → wake the user, wait reply left behind', handled === false && declined.length === 1 && r.result_type === 'make_invoice' && r.error?.code === 'OTHER', JSON.stringify(r)); } console.log('\n[guards]'); { - const { deps } = harness(); + const { deps, published, declined } = harness(); const stale = ev(makeReq('get_balance', {}, 'nip44_v2', 300)); - check('stale request refused', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === false); + // a push that sat at APNs past the request window: nothing to answer, and + // no "Payment request" for it either (true = handled quietly) + check('stale request ignored quietly', (await respondFromBg({ type: 'nwc', event: stale }, deps)) === true && !published.length && !declined.length); } { const { deps } = harness(); @@ -167,16 +194,21 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z { const rec = baseRec(); rec.mgr.vtxos = []; // nothing spendable in the mirror - const { deps } = harness({ rec }); + const { deps, published, declined, decryptReply } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); - check('empty mirror → wake the user', handled === false); + const r = declined.length ? decryptReply(declined[0]) : null; + // the wait reply is NOT published by the worker: the notifier holds it and + // sends it only if nobody answers — a sibling's payment can't be shouted over + check('empty mirror → wake the user, wait reply left with the notifier', handled === false && published.length === 0 && declined.length === 1 + && r.result_type === 'pay_invoice' && r.error?.code === 'OTHER' && /open the app/.test(r.error.message), JSON.stringify(r)); + check('…addressed to the request', declined[0].kind === 23195 && declined[0].tags.some((t) => t[0] === 'e')); } { const rec = baseRec(); delete rec.keys['5']; // change index outside the mirrored window - const { deps } = harness({ rec }); + const { deps, declined } = harness({ rec }); const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); - check('exhausted key window → wake the user', handled === false); + check('exhausted key window → wake the user, wait reply left behind', handled === false && declined.length === 1); } console.log('\n[pay outcome]'); @@ -285,6 +317,29 @@ console.log('\n[sends begun on an earlier wake]'); } } +console.log('\n[a send that fails]'); +// The ASP refused the cosign (a coin at the depth cap, PullCap 2026-10-29): +// the error is left with the notifier at once, not waited out in a worker +// iOS stops before it speaks. +{ + const { ArkManager } = await import('../src/ark/manager.js'); + const names = ['init', 'payLnInvoice']; + const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]])); + ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; }; + try { + for (const [why, code] of [ + ['insufficient ark balance (259 sat have a history too long to spend and are too few to renew; receive more to keep paying)', 'INSUFFICIENT_BALANCE'], + ['ln-pay failed: route not found', 'PAYMENT_FAILED'], + ]) { + ArkManager.prototype.payLnInvoice = async function () { throw new Error(why); }; + const { deps, published, declined, decryptReply } = harness(); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, errGraceMs: 60_000 }); + const r = declined.length ? decryptReply(declined[0]) : null; + check(`${code}: the error is handed to the notifier, nothing waited out here`, handled === true && declined.length === 1 && !published.length && r.error?.code === code && r.error.message === why, JSON.stringify(r)); + } + } finally { Object.assign(ArkManager.prototype, orig); } +} + console.log('\n[network fit]'); { // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js index ddef8d7..3cb607c 100644 --- a/tools/onboard-backup-test.js +++ b/tools/onboard-backup-test.js @@ -93,6 +93,41 @@ try { await page.close(); } + // Three: a wallet made elsewhere comes in by its twelve words, and that + // door has to be on the front page too — it used to be a line under the + // freshly minted words behind "Create a new wallet", found by chance. + console.log('\n[restoring from twelve words]'); + { + const ctx = await browser.createBrowserContext(); + const page = await ctx.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText(page, 'sign in with nostr'); + const door = await page.evaluate(() => { + const b = document.getElementById('onb-restore'); + const c = document.getElementById('onb-create'); + return b && c ? { label: b.textContent.trim(), below: b.getBoundingClientRect().top > c.getBoundingClientRect().top, visible: !!b.offsetParent } : null; + }); + check('the front page has a door for an existing wallet', !!door && door.visible && /twelve words|seed/i.test(door.label), door ? door.label : 'missing'); + check('...below the way in, with the other alternatives', !!door && door.below); + await page.evaluate(() => document.getElementById('onb-restore')?.click()); + await sleep(600); + const pane = await page.evaluate(() => ({ + title: /restore your wallet/i.test(document.body.innerText), + field: !!document.querySelector('textarea[placeholder*="seed words" i]'), + minted: document.querySelectorAll('.words .w .t').length, + })); + check('it opens straight onto the phrase field, no minted words', pane.title && pane.field && pane.minted === 0, + `${pane.title ? 'titled' : 'untitled'}, ${pane.field ? 'field' : 'no field'}, ${pane.minted} minted words`); + const phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + await page.type('textarea[placeholder*="seed words" i]', phrase); + await click(page, 'open wallet'); + check('the typed phrase opens the wallet', await waitText(page, 'receive')); + await ctx.close(); + } + console.log('\n[signing in, then writing it down]'); { const { page, ctx, seed } = await signIn(); diff --git a/tools/pager-caption-test.js b/tools/pager-caption-test.js index 5952d81..50ffe4f 100644 --- a/tools/pager-caption-test.js +++ b/tools/pager-caption-test.js @@ -55,6 +55,8 @@ const openClip = async (word) => { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); await sleep(400); const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000); // the caption and the counts show on a tap on the clip (details), as a reader sees them @@ -72,6 +74,8 @@ const clickIn = async (sel, where) => { // the caption's first word sits at its left edge; a link in the middle return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, ['(' + CAP + ')', sel, where]); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); }; const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); }; diff --git a/tools/pager-close-test.js b/tools/pager-close-test.js index 73aa15b..a7c6c12 100644 --- a/tools/pager-close-test.js +++ b/tools/pager-close-test.js @@ -84,6 +84,8 @@ const st = () => page.evaluate(() => ({ pager: !!document.querySelector('.media- 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); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); return waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); }; diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js index 3b5c5c3..3d980fb 100644 --- a/tools/pager-eager-test.js +++ b/tools/pager-eager-test.js @@ -117,6 +117,8 @@ async function run(saveData, slow = false) { await waitFor(() => { const v = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')).querySelector('video.note-video'); return v && !v.paused && v.currentTime > 0; }, null, 10000); const r = await feedRow('clip c0 here'); log = log.filter((e) => e.clip === 'c0'); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); await sleep(slow ? 8000 : 3000); diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js index 6507607..0d477b7 100644 --- a/tools/pager-first-frame-test.js +++ b/tools/pager-first-frame-test.js @@ -99,6 +99,8 @@ try { await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); await sleep(600); const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000)); diff --git a/tools/pager-no-poster-test.js b/tools/pager-no-poster-test.js index 0930834..332eb53 100644 --- a/tools/pager-no-poster-test.js +++ b/tools/pager-no-poster-test.js @@ -64,6 +64,8 @@ const openClip = async (word) => { await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word); await sleep(400); const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000); }; diff --git a/tools/pager-prime-test.js b/tools/pager-prime-test.js index 7263ee1..4989790 100644 --- a/tools/pager-prime-test.js +++ b/tools/pager-prime-test.js @@ -114,6 +114,8 @@ async function run() { await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 }); const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }); await sleep(800); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1'))); diff --git a/tools/pager-rotate-test.js b/tools/pager-rotate-test.js index 9912b7a..9dcc7e3 100644 --- a/tools/pager-rotate-test.js +++ b/tools/pager-rotate-test.js @@ -91,6 +91,8 @@ async function run(unmuteFirst) { await sleep(800); } const at = await rowOf(); await sleep(300); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 1.5; }, null, 8000); diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js index f25ff49..50863ba 100644 --- a/tools/pager-seek-hide-test.js +++ b/tools/pager-seek-hide-test.js @@ -88,6 +88,8 @@ try { await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); await sleep(400); const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000)); diff --git a/tools/pager-tail-test.js b/tools/pager-tail-test.js index b294b8f..9d537e1 100644 --- a/tools/pager-tail-test.js +++ b/tools/pager-tail-test.js @@ -103,6 +103,8 @@ try { 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); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]); await page.mouse.click(r.x, r.y); check('the pager opens on the first clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000) && await resting('c0'), await at()); check('a loading screen waits at the end, after the clips', (await tail())?.last, JSON.stringify(await tail())); diff --git a/tools/post-edit-test.js b/tools/post-edit-test.js index 0b8bf6f..2ed1260 100644 --- a/tools/post-edit-test.js +++ b/tools/post-edit-test.js @@ -160,6 +160,7 @@ try { await openFeed(); check('the feed is back', await waitText('a post a stranger tried to delete', 15000)); check('my deleted post stays gone', !(await has('my own post'))); + check('...with no "was deleted" row in its place', !(await has('deleted by its author'))); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ post edits and deletions' : '\n❌ failed'); diff --git a/tools/profile-publish-race-test.js b/tools/profile-publish-race-test.js index 390c01b..becf375 100644 --- a/tools/profile-publish-race-test.js +++ b/tools/profile-publish-race-test.js @@ -67,9 +67,10 @@ const wallet = { loadFeatureState: () => null, saveFeatureState: () => {}, _cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {}, }; +let namesAddr = null; // what the registrar says our address is const feature = messagesFeature({ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null, - hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null), + hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : name === 'namesAddress' ? namesAddr : null), }); console.log('[the migration: rename and avatar import fired together]'); @@ -91,5 +92,20 @@ await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'), const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content); check('both changes survive', last.lud16 === '[email protected]' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last)); +console.log('[Save in the editor repoints a dead same-domain address]'); +// The heal never ran (an Amber user: no background signatures), so the kind 0 +// still names the released placeholder. The domain says jar is dead, other +// is alive. +store.push(finalizeEvent({ kind: 0, created_at: now + 1000, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk)); +namesAddr = '[email protected]'; +globalThis.fetch = async (url) => /lnurlp\/jar$/.test(url) + ? { ok: false, status: 500, json: async () => null } + : { ok: true, status: 200, json: async () => ({ tag: 'payRequest', callback: 'https://x/cb' }) }; +published.length = 0; +await feature.saveProfileDraft({ uname: 'jarryd', name: 'Jarryd' }); +const saved = JSON.parse(published.filter((e) => e.kind === 0).pop().content); +check('the dead placeholder moves to the claimed address', saved.lud16 === '[email protected]', JSON.stringify(saved)); +check('a same-domain address that still answers is left alone', saved.nip05 === '[email protected]', saved.nip05); + console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns'); process.exit(fails ? 1 : 0); diff --git a/tools/quoted-clip-fullscreen-test.js b/tools/quoted-clip-fullscreen-test.js index bd8dd45..41984a2 100644 --- a/tools/quoted-clip-fullscreen-test.js +++ b/tools/quoted-clip-fullscreen-test.js @@ -70,6 +70,8 @@ try { console.log('\n[a tap on the picture]'); const box = await (await page.$(SEL)).boundingBox(); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [box.x + box.width / 2, box.y + box.height * 0.25]); await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.25); // above the middle: not the play button check('the clip goes full screen', await waitFor(() => document.fullscreenElement && document.fullscreenElement.matches('video'), 4000), JSON.stringify(await state())); await sleep(600); diff --git a/tools/receive-batch-fold-test.js b/tools/receive-batch-fold-test.js new file mode 100644 index 0000000..8e38265 --- /dev/null +++ b/tools/receive-batch-fold-test.js @@ -0,0 +1,20 @@ +// One payment delivered as several coins — a gift whose 886 sats sat in two +// (595 + 291, dust padding) swept in one claim — is one Received row. +// sameDelivery decides which receive rows fold; nothing else does. +// Run: bun tools/receive-batch-fold-test.js +import { sameDelivery } from '../src/ark/manager.js'; +let ok = true; +const check = (n, c) => { console.log(` ${c ? '✓' : '✗'} ${n}`); if (!c) ok = false; }; +const T = 1_790_000_000_000; +const rcv = (id, sat, ts, extra = {}) => ({ id, type: 'receive', status: 'complete', amountSat: sat, ts, vtxoId: 'v-' + id, ...extra }); +check('two coins of one mailbox message fold', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7' }))); +check('...even when their instants differ (live, no checkpoint time)', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T + 40, { batch: '7' }))); +check('two messages stay two rows, same instant or not', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '8' }))); +check('a stamped row never folds an unstamped one', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T))); +check('older rows (no stamp) fold on the same delivery instant', sameDelivery(rcv('a', 595, T), rcv('b', 291, T))); +check('...not a millisecond apart', !sameDelivery(rcv('a', 595, T), rcv('b', 291, T + 1))); +check('a ⚡ receive is its own payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', viaLn: true }))); +check('a rejected coin is not part of the payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', status: 'rejected' }))); +check('only receives fold', !sameDelivery({ ...rcv('a', 595, T), type: 'send' }, rcv('b', 291, T))); +console.log(ok ? '\n✅ one delivery, one row' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/relay-budget-test.js b/tools/relay-budget-test.js new file mode 100644 index 0000000..bb1dd11 --- /dev/null +++ b/tools/relay-budget-test.js @@ -0,0 +1,167 @@ +// What a Following feed asks of a relay, and how. Measured from the laptop +// (2026-11-02) an open of Following sent some 560 REQs in twenty seconds: +// a relay list asked per quoted note's author (one REQ per author per +// relay), the live subscriptions closed and reopened at the end of every +// refresh pass, the follows' reports fetched afresh at every reload, and +// every one-shot REQ held open on our fast relay until the slowest relay +// in the ask had answered. damus answered the home IP "banned: too many +// rate-limit violations". Now: relay lists go out in one REQ per batch, +// unchanged live subscriptions stay up, the reports sync honours the saved +// clock, each relay's REQ closes on its own EOSE, and no relay carries more +// than ten one-shot REQs at once. Run: bun tools/relay-budget-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { neventEncode } from 'nostr-tools/nip19'; +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 SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = () => Math.floor(Date.now() / 1000); +const post = (sk, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now() - ago, tags, content }, sk); +// six people the follow quotes, each with one note on the relay +const quoters = Array.from({ length: 6 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }); +const quotedNotes = quoters.map((q, i) => post(q.sk, `quoted thought ${i}`, 7200 + i)); +const stored = [...quotedNotes, post(SK, 'morning one', 3600), post(SK, 'morning two', 3000)]; +const quotePosts = quotedNotes.map((n, i) => post(SK, `look at this ${i} nostr:${neventEncode({ id: n.id, author: n.pubkey })}`, 1200 + i * 10, [['q', n.id, '', n.pubkey]])); +stored.push(...quotePosts); +// one quoted note only a far relay holds (the wide net's job) +const farQ = generateSecretKey(); +const farNote = finalizeEvent({ kind: 1, created_at: now() - 9000, tags: [], content: 'kept only on the far relay' }, farQ); +const farPost = post(SK, `and this nostr:${neventEncode({ id: farNote.id, author: farNote.pubkey })}`, 1100, [['q', farNote.id, '', farNote.pubkey]]); +stored.push(farPost); +const wideStore = [farNote]; +const cached = [...quotePosts, ...stored.filter((e) => e.pubkey === AUTHOR && !quotePosts.includes(e))].sort((a, b) => b.created_at - a.created_at); +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since); + +// two relays on one server: /fast answers at once, /slow holds its EOSE +const SLOW_MS = 3000; +const log = []; // { t, relay, kind: 'REQ'|'CLOSE'|'EOSE', id, filters } +const sockets = new Set(); +let maxOpen = { fast: 0, slow: 0 }; +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/fast' || path === '/slow' || path === '/wide') return srv.upgrade(req, { data: { relay: path.slice(1), subs: new Map() } }) ? undefined : new Response('ws', { status: 426 }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +}, websocket: { + open(ws) { sockets.add(ws); }, + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + const relay = ws.data.relay; + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + const filters = m.slice(2); + ws.data.subs.set(m[1], filters); + maxOpen[relay] = Math.max(maxOpen[relay], ws.data.subs.size); + log.push({ t: Date.now(), relay, kind: 'REQ', id: m[1], filters }); + const answer = () => { + if (!ws.data.subs.has(m[1])) return; + for (const e of (relay === 'wide' ? wideStore : stored)) if (filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + log.push({ t: Date.now(), relay, kind: 'EOSE', id: m[1] }); + }; + if (relay === 'slow') setTimeout(answer, SLOW_MS); else answer(); + } else if (m[0] === 'CLOSE') { ws.data.subs.delete(m[1]); log.push({ t: Date.now(), relay, kind: 'CLOSE', id: m[1] }); } + }, + close(ws) { sockets.delete(ws); }, +} }); +const PORT = server.port; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +await page.evaluateOnNewDocument((port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { + if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/fast`; + else if (/relay\.damus\.io/.test(String(url))) url = `ws://localhost:${port}/slow`; + else if (/nostr\.wine/.test(String(url))) url = `ws://localhost:${port}/wide`; + return p ? new Real(url, p) : new Real(url); + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}, PORT); +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 reqs = (relay, pred) => log.filter((x) => x.kind === 'REQ' && x.relay === relay && pred(x.filters)); +const isLive = (fs) => fs.some((f) => f.authors && f.since && !f.limit && f.kinds && f.kinds.includes(1)); +// a subscription meant to stay up: since with no limit, or limit 0 (the DM inbox) +const isSub = (fs) => fs.some((f) => (f.since && !f.limit) || f.limit === 0); + +try { + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, 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: 'Early Bird', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, cached]); + log.length = 0; maxOpen = { fast: 0, slow: 0 }; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + const t0 = Date.now(); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the quote posts are on screen', await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 6, null, 20000)); + await sleep(9000); // the refresh pass, the quotes' lookups, the reports sync + + console.log('\n[relay lists for the quoted notes\' authors]'); + const qpks = new Set(quoters.map((q) => q.pk)); + const listAsks = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds.length === 1 && f.kinds[0] === 10002 && (f.authors || []).some((a) => qpks.has(a)))); + const covered = new Set(listAsks.flatMap((x) => x.filters.flatMap((f) => f.authors || [])).filter((a) => qpks.has(a))); + check('asked for in one REQ, not one per author', listAsks.length === 1 && covered.size === 6, listAsks.length + ' REQ(s) covering ' + covered.size + ' of 6 authors'); + + console.log('\n[quoted notes]'); + const wideIds = new Set(reqs('wide', (fs) => fs.some((f) => f.ids)).flatMap((x) => x.filters.flatMap((f) => f.ids || []))); + const nearHeld = quotedNotes.filter((n) => wideIds.has(n.id)).length; + check('the ones our relay holds were never asked of the wide net', nearHeld === 0, nearHeld + ' of 6 asked on the far relay'); + check('the one only the far relay holds was found there', wideIds.has(farNote.id) && (await page.evaluate(() => document.body.innerText.includes('kept only on the far relay'))), wideIds.has(farNote.id) ? 'asked' : 'never asked'); + + console.log('\n[the live subscriptions]'); + const live = reqs('fast', isLive); + const liveClosed = live.filter((x) => log.some((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === x.id)); + check('opened once and kept across the refresh', live.length === 1 && liveClosed.length === 0, live.length + ' live REQs, ' + liveClosed.length + ' closed'); + + console.log('\n[one-shot REQs]'); + const fastReqs = reqs('fast', (fs) => !isSub(fs)); + const lag = []; + for (const r of fastReqs) { + const eose = log.find((y) => y.kind === 'EOSE' && y.relay === 'fast' && y.id === r.id); + const close = log.find((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === r.id && y.t >= r.t); + if (eose && close) { lag.push(close.t - eose.t); if (close.t - eose.t > 1500) console.log(' late close ' + (close.t - eose.t) + ' ms: ' + JSON.stringify(r.filters).slice(0, 160)); } + } + const slowLag = lag.filter((x) => x > 1500).length; + check('closed on the fast relay\'s own EOSE, not the slow relay\'s', lag.length >= 10 && slowLag === 0, lag.length + ' closed, ' + slowLag + ' later than 1.5 s (slowest ' + Math.max(0, ...lag) + ' ms; the slow relay holds EOSE ' + SLOW_MS + ' ms)'); + const slowReqs = reqs('slow', () => true).length; + check('no more than ten one-shot REQs open on a relay at once', maxOpen.slow <= 10 + 5, slowReqs + ' REQs to the slow relay, at most ' + maxOpen.slow + ' open at once (fast: ' + maxOpen.fast + ')'); + const totalFast = reqs('fast', () => true).length; + console.log(' ' + totalFast + ' REQs to the fast relay in ' + ((Date.now() - t0) / 1000).toFixed(0) + ' s'); + + console.log('\n[a reload within the half hour]'); + const reportsBefore = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length; + log.length = 0; + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + await sleep(6000); + const reportsAfter = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length; + check('the follows\' reports are not fetched again', reportsBefore >= 1 && reportsAfter === 0, reportsBefore + ' REQ(s) on the first open, ' + reportsAfter + ' after the reload'); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the feed asks the relays for less, and asks it in fewer REQs' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/resume-wake-test.js b/tools/resume-wake-test.js new file mode 100644 index 0000000..3b992fb --- /dev/null +++ b/tools/resume-wake-test.js @@ -0,0 +1,111 @@ +// Back from the background on a phone, the app's relay sockets read OPEN +// and are dead (the tab was frozen; the network forgot them), and the +// catch-up it sends on them finds nothing. The library noticed a dead one +// only after its 29 s ping + 20 s timeout, then waited 10 s to redial — a +// Following feed stuck at the morning's posts for a minute or more after +// opening the app in the evening ("no new notes since 11:35?", Adam, +// 2026-11-01). On resume every open socket is now asked one tiny REQ; a +// silent one is closed and redialed at once, its subscriptions refired, and +// the feed refresh runs on the live sockets. Run: bun tools/resume-wake-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 html = await buildHtml({ minify: true, pwa: false }); +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 stored = [post('morning one', 3600), post('morning two', 3000), post('morning three', 2400)]; +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since); +// one relay; a socket marked dead keeps its connection and answers nothing, +// the way a phone's frozen sockets do once the network has forgotten them +const sockets = new Set(); let opened = 0, closed = 0; +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +}, websocket: { + open(ws) { ws.data = { subs: new Map(), dead: false }; sockets.add(ws); opened++; }, + message(ws, raw) { + if (ws.data.dead) return; + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + const s = { id: m[1], filters: m.slice(2) }; + ws.data.subs.set(m[1], s); + for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e])); + ws.send(JSON.stringify(['EOSE', m[1]])); + } else if (m[0] === 'CLOSE') ws.data.subs.delete(m[1]); + }, + close(ws) { sockets.delete(ws); closed++; }, +} }); +const PORT = server.port; +const liveSubs = () => [...sockets].filter((s) => !s.data.dead).flatMap((s) => [...s.data.subs.values()]); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +await page.evaluateOnNewDocument((port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}, PORT); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const warns = []; +page.on('console', (m) => { if (/redialing|silent after resume/.test(m.text())) warns.push(m.text()); }); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { const t0 = Date.now(); for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return (Date.now() - t0) / 1000; await sleep(100); } return null; }; +const rowTexts = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 40))); + +try { + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, 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: 'Early Bird', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, stored]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the morning\'s posts are in the feed', (await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 3, null, 20000)) != null, (await rowTexts()).join(' | ')); + check('the feed watches the relay live', (await waitFor(() => true, null, 100), await (async () => { for (let i = 0; i < 100; i++) { if (liveSubs().some((s) => s.filters.some((f) => f.authors && f.since))) return true; await sleep(100); } return false; })()), liveSubs().length + ' subs on ' + sockets.size + ' sockets'); + const openedBefore = opened; + + console.log('\n[the phone sleeps: its sockets die quietly, a follow posts]'); + for (const s of sockets) s.data.dead = true; + const fresh = post('evening news', 0); + stored.push(fresh); + await page.evaluate((k) => { try { localStorage.setItem('btc-wallet-bg-at', String(Date.now() - 10 * 60_000)); } catch {} }, base); + await sleep(300); + + console.log('\n[back on screen]'); + const t0 = Date.now(); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + const redial = await (async () => { for (let i = 0; i < 150; i++) { if (opened > openedBefore) return (Date.now() - t0) / 1000; await sleep(100); } return null; })(); + check('the dead socket is found out and redialed within seconds', redial != null && redial < 6, redial != null ? redial.toFixed(1) + 's (' + warns[0] + ')' : 'no new socket in 15 s'); + const shown = await waitFor(() => { const p = document.querySelector('.feed-new-pill'); if (p) p.click(); return [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes('evening news')); }, null, 15000); + check('the post from while it slept is on screen within seconds', shown != null && (Date.now() - t0) / 1000 < 12, shown != null ? ((Date.now() - t0) / 1000).toFixed(1) + 's after resume' : (await rowTexts()).join(' | ')); + check('the dead sockets were let go', closed >= 1, closed + ' closed'); + check('…and the live subscriptions are back on the new socket', liveSubs().some((s) => s.filters.some((f) => f.authors && f.since)), liveSubs().length + ' subs'); + 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✅ back from the background, dead sockets are redialed at once and the feed catches up' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/scheduled-post-test.js b/tools/scheduled-post-test.js new file mode 100644 index 0000000..546537c --- /dev/null +++ b/tools/scheduled-post-test.js @@ -0,0 +1,99 @@ +// Scheduled posts: the composer's clock button picks a time; Schedule signs +// the note with that created_at and hands it to the notifier (POST +// nwcpush /schedule), nothing goes to a relay, the composer closes and the +// post waits in a "Scheduled" list, where Cancel sends a kind 5 for it to +// /schedule/cancel. The notifier is stubbed. +// Run: bun tools/scheduled-post-test.js +import puppeteer from './puppeteer.js'; +import { verifyEvent } 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 html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5316, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); 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(); +await page.evaluateOnNewDocument(() => { + window.__published = []; + 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') { window.__published.push(m[1]); setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); return; } } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}); +const held = new Map(); +const calls = []; +await page.setRequestInterception(true); +page.on('request', (r) => { + const u = new URL(r.url()); + if (u.host !== 'nwcpush.coinos.io' || !u.pathname.startsWith('/schedule')) return r.continue(); + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' }; + if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '' }); + const body = r.postData() ? JSON.parse(r.postData()) : null; + calls.push({ path: u.pathname, body }); + let res = { ok: true }; + if (u.pathname === '/schedule') held.set(body.event.id, body.event); + if (u.pathname === '/schedule/cancel') { let n = 0; for (const [k, id] of body.event.tags) if (k === 'e' && held.delete(id)) n++; res = { ok: true, cancelled: n }; } + if (u.pathname === '/schedule/status') res = Object.fromEntries(u.searchParams.get('ids').split(',').map((id) => [id, held.has(id) ? 'pending' : 'gone'])); + r.respond({ status: 200, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(res) }); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 15000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5316/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + await click('skip verification'); + await waitText('receive', 20000); + + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000); + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + await waitFor(() => !!document.querySelector('.post-input'), 8000); + await page.focus('.post-input'); + await page.keyboard.type('later, #coinos', { delay: 10 }); + await page.evaluate(() => document.querySelector('.post-actions button[aria-label="Schedule for later"]').click()); + check('the clock button shows a time field, about an hour ahead', await waitFor(() => !!document.querySelector('.sched-pick input[type=datetime-local]'), 5000)); + const shown = await page.$eval('.sched-pick input', (e) => e.value); + const shownAt = new Date(shown).getTime(); + check('...on a quarter hour', shownAt - Date.now() > 3500_000 && shownAt - Date.now() < 4500_000 && new Date(shownAt).getMinutes() % 15 === 0, shown); + check('the main button reads Schedule', await page.evaluate(() => document.querySelector('.post-actions .btn-primary').textContent.trim() === 'Schedule')); + // two hours ahead, typed as the user would + const want = new Date(Date.now() + 2 * 3600_000); want.setSeconds(0, 0); + const local = new Date(want.getTime() - want.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + await page.evaluate((v) => { const i = document.querySelector('.sched-pick input'); i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); }, local); + await page.evaluate(() => { window.__published = []; document.querySelector('.post-actions .btn-primary').click(); }); + await waitFor(() => !document.querySelector('.post-input'), 8000); + const sc = calls.find((c) => c.path === '/schedule'); + const ev = sc && sc.body.event; + check('the notifier gets a signed kind 1 dated at the chosen time', !!ev && ev.kind === 1 && verifyEvent(ev) && ev.created_at === Math.floor(want.getTime() / 1000), ev && new Date(ev.created_at * 1000).toISOString()); + check('...with its tags (t coinos, client) and the relays to send it to', !!ev && ev.tags.some((x) => x[0] === 't' && x[1] === 'coinos') && ev.tags.some((x) => x[0] === 'client') && sc.body.relays.length > 0, JSON.stringify(sc?.body.relays)); + check('nothing went to a relay', !(await page.evaluate(() => window.__published.some((e) => e.kind === 1)))); + check('the composer closed and the draft is cleared', await page.evaluate(() => !document.querySelector('.post-input'))); + check('the post is not in the feed yet', await page.evaluate(() => !document.body.innerText.includes('later, #coinos') || !!document.querySelector('.sched-list')) && !(await page.evaluate(() => [...document.querySelectorAll('.note-text')].some((n) => n.textContent.includes('later, #coinos'))))); + + // the list shows with the composer open and on your profile + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + check('the composer lists it under Scheduled (1)', await waitFor(() => /Scheduled \(1\)/.test(document.querySelector('.sched-list')?.innerText || ''), 5000)); + check('...with its words', await page.evaluate(() => document.querySelector('.sched-item').innerText.includes('later, #coinos'))); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000); + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + check('it survives a reload (and the status check keeps it)', await waitFor(() => !!document.querySelector('.sched-item'), 8000) && calls.some((c) => c.path === '/schedule/status')); + await page.evaluate(() => [...document.querySelectorAll('.sched-item button')].find((b) => b.textContent.trim() === 'Cancel').click()); + await waitFor(() => !document.querySelector('.sched-item'), 8000); + const cc = calls.find((c) => c.path === '/schedule/cancel'); + check('Cancel sends a kind 5 naming it, signed by the author', !!cc && cc.body.event.kind === 5 && verifyEvent(cc.body.event) && cc.body.event.pubkey === ev.pubkey && cc.body.event.tags.some((x) => x[0] === 'e' && x[1] === ev.id)); + check('...and it leaves the list', await page.evaluate(() => !document.querySelector('.sched-list'))); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { check('ran', false, e.stack); } +await browser.close(); server.stop(); +console.log(ok ? '\nALL PASS' : '\nFAILED'); +process.exit(ok ? 0 : 1); diff --git a/tools/signer-resume-test.js b/tools/signer-resume-test.js new file mode 100644 index 0000000..c35b655 --- /dev/null +++ b/tools/signer-resume-test.js @@ -0,0 +1,140 @@ +// A remote signer (Amethyst) that wasn't heard the moment the app needed it +// — the tab was frozen, a socket cut, the signer app asleep — put up +// "Reconnect your signer" and then never tried the stored session again, so +// every time meant a brand-new connection (Adam's phone, 2026-10-29: the +// stored session answered in 0.7 s the moment anyone asked). Now the screen +// keeps trying the stored session and closes itself once the signer answers. +// The remote signer is played by this script over a shimmed relay. +// Run: bun tools/signer-resume-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import * as nip44 from 'nostr-tools/nip44'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const SK = generateSecretKey(); const PK = getPublicKey(SK); // the account, held by the remote signer +const CLIENT_SK = generateSecretKey(); const CLIENT = getPublicKey(CLIENT_SK); // the client key it authorised long ago + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5319, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p === '/nip46.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); 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(); +await page.setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Mobile Safari/537.36'); +await page.evaluateOnNewDocument((fx) => { + window.__amberUrls = []; + window.__coinosAmberLaunch = (u) => { window.__amberUrls.push(u); }; + window.__published = []; + const subs = []; + const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f['#p'] || ev.tags.some((t) => t[0] === 'p' && f['#p'].includes(t[1]))) + && (!f.authors || f.authors.includes(ev.pubkey)) && (!f.since || ev.created_at >= f.since); + // the test pushes the signer's answers through here, to every live sub that wants them + window.__inject = (ev) => { for (const s of subs) if (s.fs.some((f) => fits(ev, f))) s.ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', s.id, ev]) })); }; + 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') { + const fs = m.slice(2); + for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1); + subs.push({ ws, id: m[1], fs }); + const evs = fx.filter((ev) => fs.some((f) => fits(ev, f))); + setTimeout(() => { + for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); + }, 0); + return; + } + if (m[0] === 'CLOSE') { for (let i = subs.length - 1; i >= 0; i--) if (subs[i].ws === ws && subs[i].id === m[1]) subs.splice(i, 1); return; } + if (m[0] === 'EVENT') { + window.__published.push(m[1]); + setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); + 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 text = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; + +// The remote signer: answers the connect, then get_public_key / nip44_decrypt. +let client = CLIENT, awake = false, handled = new Set(), decrypts = 0; +const signerLoop = async () => { + if (!awake) return; + const pub = await page.evaluate(() => window.__published.filter((e) => e.kind === 24133)); + for (const ev of pub) { + if (handled.has(ev.id) || !client || ev.pubkey !== client) continue; + handled.add(ev.id); + const ck = nip44.getConversationKey(SK, ev.pubkey); + const req = JSON.parse(nip44.decrypt(ev.content, ck)); + let result; + if (req.method === 'get_public_key') result = PK; + else if (req.method === 'nip44_decrypt') { decrypts++; result = nip44.decrypt(req.params[1], nip44.getConversationKey(SK, req.params[0])); } + else if (req.method === 'nip44_encrypt') result = nip44.encrypt(req.params[1], nip44.getConversationKey(SK, req.params[0])); + else if (req.method === 'sign_event') { const u = JSON.parse(req.params[0]); result = JSON.stringify(finalizeEvent(u, SK)); } + else if (req.method === 'ping') result = 'pong'; + else if (req.method === 'connect') result = 'ack'; + const resp = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [['p', ev.pubkey]], content: nip44.encrypt(JSON.stringify({ id: req.id, result }), ck) }, SK); + await page.evaluate((e) => window.__inject(e), resp); + } +}; + + +const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +const pump = async (ms) => { for (let i = 0; i < ms / 250; i++) { await signerLoop(); await sleep(250); } }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5319/', { 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 local = Array.from(CLIENT_SK, (b) => b.toString(16).padStart(2, '0')).join(''); + await page.evaluate(([k, pk, local]) => { + localStorage.setItem(k + ':nostrlogin', JSON.stringify({ pubkey: pk, linked: Date.now(), via: 'bunker', + session: { local, bp: { pubkey: pk, relays: ['wss://relay.coinos.io'], secret: null } } })); + }, [base, PK, local]); + + console.log('\n[the signer is asleep when the app needs it]'); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); + await waitFor(() => !!document.querySelector('.feed-post-fab')); + await page.evaluate(() => document.querySelector('.feed-post-fab').click()); + await waitFor(() => !!document.querySelector('.post-input'), 8000); + await page.focus('.post-input'); + await page.keyboard.type('hello', { delay: 10 }); + await page.evaluate(() => document.querySelector('.post-actions .btn-primary').click()); + check('the reconnect screen comes up', await waitText('reconnect your signer', 45000)); + await sleep(16000); // let the screen's first try run out unanswered + check('...and stays while the signer is silent', (await text()).toLowerCase().includes('reconnect your signer')); + + console.log('\n[the signer wakes up]'); + awake = true; + const t0 = Date.now(); + let gone = false; + for (let i = 0; i < 160 && !gone; i++) { await signerLoop(); gone = !(await text()).toLowerCase().includes('reconnect your signer'); if (!gone) await sleep(250); } + check('the screen closes by itself, no new connection', gone, gone ? `${Math.round((Date.now() - t0) / 1000)} s after the signer answered` : 'still up after 40 s'); + const st = await page.evaluate((k) => JSON.parse(localStorage.getItem(k + ':nostrlogin') || '{}'), base); + check('the stored session is the same one', st.session && st.session.local === local); + check('no nostrconnect link was needed', !(await page.evaluate((pk) => window.__published.some((e) => e.kind === 24133 && e.pubkey !== pk), CLIENT))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a stored remote-signer session comes back by itself' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js index 049dc1f..cf4a7bf 100644 --- a/tools/sw-push-quota.test.js +++ b/tools/sw-push-quota.test.js @@ -6,7 +6,7 @@ import { test, expect, mock } from 'bun:test'; let respond = async () => true; mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] })); mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() })); -mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {} })); +mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {}, takeLease: async () => ({ waited: 0, release: async () => {} }) })); mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} })); mock.module('../src/dm-inbox.js', () => ({ allInboxes: async () => [{ muted: ['m'.repeat(64)] }], diff --git a/tools/thread-like-count-test.js b/tools/thread-like-count-test.js new file mode 100644 index 0000000..6d81b3f --- /dev/null +++ b/tools/thread-like-count-test.js @@ -0,0 +1,91 @@ +// The heart's number in a thread matches the people the who-panel lists. +// A thread remembers its counts between visits; a reaction that arrived +// through the feed's own count fetch before the thread was opened again +// must raise the remembered number, not be outvoted by it. +// Run: bun tools/thread-like-count-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure'; +import { nip19 } from 'nostr-tools'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000); +const notes = Array.from({ length: 12 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 60 - i * 60, tags: [], + content: `Post number ${i}. The tide was out and the gulls had the whole beach to themselves.` }, sk)); +const target = notes[0]; +const react = (n) => finalizeEvent({ kind: 7, created_at: now - 30 + n, content: '+', tags: [['e', target.id], ['p', target.pubkey]] }, generateSecretKey()); +const reactions = [react(1), react(2)]; +const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Poster' }) }, sk); +const html = await buildHtml({ minify: true, pwa: false }); +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes, profiles: [profile], popUntil: now - 3600 }); +const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/api/feed' + ? new Response(snapshot, { headers: { 'content-type': 'application/json' } }) + : new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(String(e))); + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); + // The relay serves as many of the two reactions as localStorage says + // (one on the first visit, both on the second). + await page.evaluateOnNewDocument(([notes, profile, reactions]) => { + 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); + const n = Number(localStorage.getItem('test-reactions') || 1); + let evs = []; + 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)) && (!f.ids || f.ids.includes(e.id))).slice(0, f.limit || 100); + if (f.kinds?.includes(0) && f.authors?.includes(profile.pubkey)) evs = [profile]; + if (f['#e']) evs = f.kinds?.includes(7) ? reactions.slice(0, n).filter((r) => f['#e'].includes(r.tags[0][1])) : []; + if (f.ids) evs = notes.filter((e) => f.ids.includes(e.id)); + 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; + }, [notes, profile, reactions]); + const heartSel = (scope, id) => `${scope} .note-post[data-zap-post="${id}"] .note-acts > .note-act:nth-child(4) .note-act-n`; + const heart = (scope, id) => page.evaluate((s) => document.querySelector(s)?.textContent || '', heartSel(scope, id)); + const openRow = (id) => page.evaluate((id) => document.querySelector('.notes-feed .note-post[data-zap-post="' + id + '"] > .note-content > .note-text').click(), id); + const visit = async (n, path = '/feed') => { + await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' }); + await page.evaluate((n) => localStorage.setItem('test-reactions', String(n)), n); + await page.goto(server.url.origin + path, { waitUntil: 'domcontentloaded' }); + }; + // first visit: the thread itself, from a link (a notification, say), with + // one heart on the relay — the count answer lands while the thread is + // open, so the thread remembers it + await visit(1, '/' + nip19.neventEncode({ id: target.id })); + await page.waitForFunction((s) => document.querySelector(s)?.textContent === '1', { timeout: 15000 }, heartSel('.thread-page', target.id)); + await sleep(1500); // the thread is persisted + // second visit: the feed, where the count fetch brings both hearts; the + // thread is then opened from the row + await visit(2); + await page.waitForFunction(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 5, { timeout: 20000 }); + await page.waitForFunction((s) => document.querySelector(s)?.textContent === '2', { timeout: 10000 }, heartSel('.notes-feed', target.id)); + await openRow(target.id); + await page.waitForFunction((s) => document.querySelector(s), { timeout: 10000 }, heartSel('.thread-page', target.id)); + await sleep(800); + const n = await heart('.thread-page', target.id); + const who = await page.evaluate((id) => { + const row = document.querySelector('.thread-page .note-post[data-zap-post="' + id + '"]'); + row.querySelector('.note-acts > .note-act:last-child').click(); + return new Promise((r) => setTimeout(() => r(row.querySelectorAll('.note-who-person').length), 300)); + }, target.id); + assert.equal(who, 2, 'the panel lists both people'); + assert.equal(n, '2', `the heart in the thread says 2, not the remembered 1 (got "${n}")`); + assert.deepEqual(errors, []); + console.log('✓ the thread\'s heart count catches up with the people who reacted'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js index 738a31c..f3bda91 100644 --- a/tools/thread-reply-test.js +++ b/tools/thread-reply-test.js @@ -78,6 +78,27 @@ try { await page.click('#app .note-act'); check('Reply immediately shows its composer beneath the tapped post', await inline()); check('the input receives focus before relay queries finish', await page.evaluate(() => document.activeElement.matches('.thread-reply-input'))); + // Cancel with text typed: the box folds and the draft is gone (it used to + // stay open — the box shows while a draft has text, and Cancel only + // cleared the replying flag) + await page.type('.thread-reply-input', 'Half a thought'); + await page.evaluate(() => document.querySelector('.thread-reply-cancel').click()); + check('Cancel with text typed hides the box and discards the draft', await page.evaluate(() => + !document.querySelector('.thread-reply-input') && !(test.ui.noteThread.draft || '').length)); + await page.evaluate(() => document.querySelector('#app .note-act').click()); // one atomic tap, as below + await page.waitForSelector('.thread-reply-input'); + check('Reply again opens an empty field', await page.evaluate(() => document.querySelector('.thread-reply-input')?.value === '')); + // a phone: Return is a new line, not a send + await page.evaluate(() => { const mm = window.matchMedia; window._mm = mm; window.matchMedia = (q) => q === '(pointer: coarse)' ? { matches: true } : mm(q); }); + await page.type('.thread-reply-input', 'Line one'); + await page.keyboard.press('Enter'); + await page.type('.thread-reply-input', 'two'); + check("on a phone, Return adds a line instead of sending", await page.evaluate(() => + document.querySelector('.thread-reply-input')?.value === 'Line one\ntwo' && sent.length === 0), await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)); + await page.evaluate(() => { window.matchMedia = window._mm; }); + await page.focus('.thread-reply-input'); + await page.keyboard.down('Control'); await page.keyboard.press('a'); await page.keyboard.up('Control'); + await page.keyboard.press('Backspace'); await page.type('.thread-reply-input', 'A reply before the root loads'); // (one atomic tap: a repaint landing between finding the button and clicking it replaces it) await page.waitForFunction(() => !document.querySelector('.thread-reply-send')?.disabled); diff --git a/tools/video-events-test.js b/tools/video-events-test.js index bef0956..c024949 100644 --- a/tools/video-events-test.js +++ b/tools/video-events-test.js @@ -128,6 +128,8 @@ try { await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }); await sleep(500); const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + // a mouse click on a playing clip pauses it; a paused one opens + await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]); await page.mouse.click(at.x, at.y); check('a tap opens the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000)); const slides = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('|')[1].replace(location.origin, ''))); diff --git a/tools/video-gone-test.js b/tools/video-gone-test.js new file mode 100644 index 0000000..95826b5 --- /dev/null +++ b/tools/video-gone-test.js @@ -0,0 +1,129 @@ +// A post whose clip its host no longer has (deleted from Primal's blossom: +// a 404 with no CORS headers, which the probe can't read) sat in the feed +// as a dead player under "Tap to play" that did nothing when tapped — +// "Video in this post won't play" (Adam, 2026-11-01). The player is the +// first to know the file is gone: it now says so where it stood, with the +// file's link, its controls and cover gone; the next paint of the post says +// it at once, with no request. A clip on a live host plays as before. +// Run: bun tools/video-gone-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 GOOD = '/tmp/coinos-vgone-good.webm'; +if (!existsSync(GOOD)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10', '-t', '8', '-c:v', 'libvpx', '-b:v', '100k', GOOD]); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const html = await buildHtml({ minify: !process.env.VGONE_LOG, pwa: false }); +const app = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +// the clips' host: another origin, no CORS headers at all (Primal's blossom) +let deadHits = 0; +const host = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path === '/dead.mp4') { deadHits++; if (process.env.VGONE_LOG) console.error('dead.mp4 asked:', req.method, req.headers.get('range') || '-', req.headers.get('sec-fetch-dest'), req.headers.get('sec-fetch-mode')); return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } }); } + if (path === '/good.webm') { + const f = Bun.file(GOOD), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || ''); + if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } }); + const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1; + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } }); + } + return new Response('no', { status: 404 }); +} }); +const DEAD = host.url.origin + '/dead.mp4', LIVE = host.url.origin + '/good.webm'; +const post = (content, url, ago, m) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'm ' + m]], content }, SK); +const seeded = [post('lol beat it ' + DEAD, DEAD, 100, 'video/mp4'), post('a clip that is there ' + LIVE, LIVE, 200, 'video/webm'), + ...Array.from({ length: 4 }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 600 - i * 60, tags: [], content: 'filler ' + i + ' lorem ipsum dolor sit amet. '.repeat(4) }, SK))]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + 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))); +if (process.env.VGONE_LOG) page.on('request', (r) => { if (r.url().endsWith('/dead.mp4')) console.error('REQ', r.resourceType(), JSON.stringify(r.initiator()).slice(0, 900)); }); +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 boxOf = (word) => page.evaluate((w) => { + const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); + const b = r && r.querySelector('.note-video-box'); if (!b) return null; + const v = b.querySelector('video'); + return { gone: b.classList.contains('gone'), line: (b.querySelector('.vid-nopic') || {}).textContent || '', link: (b.querySelector('.vid-nopic a') || {}).href || '', cover: !!b.querySelector('.vid-heavy'), pp: !!b.querySelector('.vid-pp'), controls: !!v.controls, height: Math.round(b.getBoundingClientRect().height), playing: !v.paused, src: v.getAttribute('src') }; +}, word); +const scrollTo = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); }, word); + await sleep(1500); +}; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(app.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + const open = async () => { + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 2, null, 20000)); + }; + + console.log('\n[the clip whose host answers 404, without CORS]'); + await open(); + await scrollTo('beat it'); + let b = await boxOf('beat it'); + check('the probe could not tell: the clip waits under a cover', !!b && b.cover && !b.gone, JSON.stringify(b)); + // the tap that used to do nothing + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('.vid-heavy').click(); }, 'beat it'); + check('the player finds the file gone and says so', await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return !!r.querySelector('.note-video-box.gone .vid-nopic'); }, 'beat it', 8000)); + b = await boxOf('beat it'); + check('…the line names the host, with the file\'s link', /couldn’t be loaded from its host/.test(b.line) && b.link === DEAD, JSON.stringify(b)); + check('…no cover, no play button, no controls', !b.cover && !b.pp && !b.controls, JSON.stringify(b)); + check('…a short box, not a screen of black', b.height > 40 && b.height <= 200, String(b.height)); + const hitsThen = deadHits; + await sleep(2500); // repaints, the in-view hook: nothing asks again + b = await boxOf('beat it'); + check('the line stays through repaints', b.gone && !!b.line && !b.cover, JSON.stringify(b)); + + console.log('\n[the clip on a live host]'); + await scrollTo('that is there'); + // the same quiet host: the cover, and the tap that plays it + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const c = r.querySelector('.vid-heavy'); if (c) c.click(); }, 'that is there'); + check('plays as before', await waitFor((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const v = r.querySelector('video'); return !!v && !v.paused && v.readyState >= 2; }, 'that is there', 10000), JSON.stringify(await boxOf('that is there'))); + b = await boxOf('that is there'); + check('…and is not marked gone', !b.gone && !b.line, JSON.stringify(b)); + + console.log('\n[the next time the post is painted]'); + await open(); + await scrollTo('beat it'); + b = await boxOf('beat it'); + check('the post says it at once', !!b && b.gone && /couldn’t be loaded/.test(b.line) && !b.cover, JSON.stringify(b)); + await sleep(1500); + check('…without asking the host again', deadHits === hitsThen, deadHits + ' vs ' + hitsThen); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); app.stop(true); host.stop(true); } +console.log(ok ? '\n✅ a clip its host no longer has says so, in place of a dead player' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/video-tap-test.js b/tools/video-tap-test.js index f82238b..50e5a69 100644 --- a/tools/video-tap-test.js +++ b/tools/video-tap-test.js @@ -1,8 +1,7 @@ -// Tapping a video: in the feed it opens the media pager (feed-video-pager- -// test.js has the rest). In a thread, anywhere on the picture (not the -// player's own play button or control bar) goes full screen — the corner -// button is a reach on a phone. Full screen, a tap pauses a playing clip; a -// tap on a paused one comes back out. Taps on the controls stay the player's. +// Tapping a video: inline, a playing clip pauses. A paused one, in the feed, +// and in a thread opens the media pager (feed-video-pager-test.js has the +// rest). With a mouse the middle of the clip is a big play/pause button that +// never goes full screen. // Run: bun tools/video-tap-test.js import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; @@ -69,8 +68,18 @@ try { for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250); check('the clip autoplays in the feed', !(await st()).paused); + // the hover button: pauses and plays in place + const pp = async () => { const b = await page.evaluate(() => { const r = document.querySelector('.notes-feed .note-video-box').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.move(b.x - 40, b.y); await page.mouse.move(b.x, b.y, { steps: 3 }); await sleep(250); if (!(await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'flex'))) check('a mouse over the clip brings up the middle button', false); await page.mouse.click(b.x, b.y); await sleep(700); }; + check('no middle button until a mouse comes over the clip', await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'none')); + await pp(); + check('the middle button pauses the clip in place', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement)); + check('...and shows play while paused', await page.evaluate(() => !document.querySelector('.vid-pp').classList.contains('playing'))); + await pp(); + check('the middle button plays it again, still inline', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement)); await tap(0.2, 0.3); - check('in the feed, a tap opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + check('in the feed, a tap on a playing clip pauses it, no pager', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager'))); + await tap(0.2, 0.3); + check('a tap on the paused clip opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); await page.keyboard.press('Escape'); await sleep(700); check('...which Escape closes', await page.evaluate(() => !document.querySelector('.media-pager') && !document.fullscreenElement)); // the thread: the post's words open it @@ -87,22 +96,11 @@ try { for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250); await tap(0.2, 0.3); let s = await st(); - check('in a thread, a tap on the picture goes full screen', s.fs, JSON.stringify(s)); - check('...and not the pager', await page.evaluate(() => !document.querySelector('.media-pager'))); - await tap(0.25, 0.3); - s = await st(); - check('full screen, a tap pauses the playing clip', s.fs && s.paused, JSON.stringify(s)); - await tap(0.5, 0.5); - s = await st(); - check('a tap on the paused clip\'s play button is left to the player', s.fs, JSON.stringify(s)); - await page.evaluate(() => document.querySelector('.thread-card video.note-video').pause()); await sleep(300); + check('in a thread, a tap on the playing clip pauses it in place', !s.fs && s.paused, JSON.stringify(s)); await tap(0.2, 0.3); - s = await st(); - check('a tap elsewhere on the paused clip comes out of full screen', !s.fs, JSON.stringify(s)); - await tap(0.3, 0.97); - s = await st(); - check('a tap on the control bar stays the player\'s', !s.fs, JSON.stringify(s)); + check('a tap on the paused clip opens the media pager there too', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + await page.keyboard.press('Escape'); await sleep(700); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } finally { await browser.close(); server.stop(true); } -console.log(ok ? '\n✅ video taps: full screen, pause, back out' : '\n❌ failed'); +console.log(ok ? '\n✅ video taps: pause in place, pager when paused, middle button' : '\n❌ failed'); process.exit(ok ? 0 : 1); diff --git a/tools/web-comment-test.js b/tools/web-comment-test.js new file mode 100644 index 0000000..7a6258f --- /dev/null +++ b/tools/web-comment-test.js @@ -0,0 +1,123 @@ +// Comments scoped to a web address (NIP-22 kind 1111 with an I tag: +// DaddyTube's, on a film's IMDb page) belong with the person's replies. +// Adam's own ("Charming and wholesome", 2026-11-02, on relay.primal.net) +// showed on neither his profile's Replies tab — the profile page never +// asked for kind 1111 — nor said what it was a comment on. Now the page +// asks for comments, and a web-scoped comment carries the page it is on +// where "Replying to" would go, named by its alt tag when it has one. +// Also here: a report naming a post as 'other', with the author's bare p +// tag beside it, counted as a report of the PERSON (es stayed 0 for an +// 'other' e tag) — two such reports from follows folded the author's every +// post. Run: bun tools/web-comment-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 html = await buildHtml({ minify: true, pwa: false }); +const keys = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }; +const B = keys(), C = keys(), D = keys(); // B comments; C and D report +const now = () => Math.floor(Date.now() / 1000); +const ev = (sk, kind, content, ago, tags = []) => finalizeEvent({ kind, created_at: now() - ago, tags, content }, sk); +const IMDB = 'https://www.imdb.com/title/tt32565993/'; +const post = ev(B.sk, 1, 'a plain post about sheep', 3600); +const comment = ev(B.sk, 1111, 'Charming and wholesome', 1800, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web']]); +const named = ev(B.sk, 1111, 'The dog steals every scene', 1700, [['I', IMDB], ['K', 'web'], ['i', IMDB], ['k', 'web'], ['alt', 'Comment on Sheep Detectives (2025) in DaddyTube']]); +const reports = [C, D].map((r) => ev(r.sk, 1984, '', 900, [['e', post.id, 'other'], ['p', B.pk]])); +const stored = [post, comment, named, ...reports]; +const reqLog = [], sentLog = []; +const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since); +const server = Bun.serve({ port: 0, fetch: (req, srv) => { + const path = new URL(req.url).pathname; + if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +}, websocket: { + message(ws, raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; } + if (m[0] === 'REQ') { + reqLog.push(m.slice(2)); + for (const e of stored) if (m.slice(2).some((f) => match(f, e))) { sentLog.push(e.content.slice(0, 20)); ws.send(JSON.stringify(['EVENT', m[1], e])); } + ws.send(JSON.stringify(['EOSE', m[1]])); + } + }, +} }); +const PORT = server.port; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.setViewport({ width: 390, height: 844 }); +await page.evaluateOnNewDocument((port) => { + const Real = window.WebSocket; + window.WebSocket = function (url, p) { if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); +}, PORT); +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++) { const r = await page.evaluate(fn, arg); if (r) return r; await sleep(100); } return null; }; +const rowWith = (text) => page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return r ? { folded: !!r.querySelector('.note-folded') || r.classList.contains('note-folded'), scope: r.querySelector('.note-scope a') ? { href: r.querySelector('.note-scope a').href, text: r.querySelector('.note-scope a').textContent.trim() } : null, text: r.textContent.replace(/\s+/g, ' ').trim().slice(0, 120) } : null; }, text); + +try { + await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pks, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pks[0]]: { name: 'Shaun', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, [B.pk, C.pk, D.pk], [post]]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the post is in Following', !!(await waitFor((x) => [...document.querySelectorAll('.notes-feed > .row[data-key]')].some((r) => r.textContent.includes(x)), 'plain post', 20000))); + await sleep(5000); // the follows' reports arrive and the feed repaints + const plain = await rowWith('plain post'); + check('two "other" reports of the post, each with the author\'s bare p tag, do not fold the author', plain && !plain.folded, plain ? plain.text : 'row gone'); + + console.log('\n[Conversations]'); + await click('conversations'); + const row = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 15000); + check('the web-scoped comment is listed', !!row); + if (process.env.DEBUG) { + console.log(' rows:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.row[data-key]')].map((r) => r.textContent.replace(/\s+/g, ' ').trim().slice(0, 50))))); + console.log(' chips:', JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : ''))))); + console.log(' REQs with 1111:', JSON.stringify(reqLog.filter((fs) => fs.some((f) => (f.kinds || []).includes(1111))).map((fs) => fs.map((f) => ({ kinds: f.kinds, authors: (f.authors || []).length, limit: f.limit, since: !!f.since }))))); + console.log(' sent:', JSON.stringify(sentLog)); + } + const c1 = await rowWith('Charming and wholesome'); + check('…under the page it is on, linked', !!c1?.scope && c1.scope.href === IMDB && c1.scope.text.includes('Comment on imdb.com/title/tt32565993'), c1 ? JSON.stringify(c1.scope) : ''); + check('…not tagged as a plain reply', !!c1 && !/↩\s*reply/i.test(c1.text), c1 ? c1.text : ''); + const c2 = await rowWith('dog steals'); + check('a comment with an alt tag is labelled by it', !!c2?.scope && c2.scope.text.includes('Comment on Sheep Detectives (2025) in DaddyTube'), c2 ? JSON.stringify(c2.scope) : 'row missing'); + + console.log('\n[the profile\'s Replies tab]'); + await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-avatar').click(); }, 'Charming and wholesome'); + check('the profile opens', await waitText('replies', 10000)); + await click('replies'); + const onProfile = await waitFor((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); return !!r; }, 'Charming and wholesome', 12000); + check('the comment is on the Replies tab', !!onProfile); + const p1 = await rowWith('Charming and wholesome'); + check('…with its page above it', !!p1?.scope && p1.scope.href === IMDB, p1 ? JSON.stringify(p1.scope) : ''); + console.log('\n[the comment opened]'); + await page.evaluate((x) => { const r = [...document.querySelectorAll('.row[data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-content').click(); }, 'Charming and wholesome'); + const opened = await waitFor((id) => location.pathname.startsWith('/nevent1') && !!document.querySelector('.note-post[data-zap-post="' + id + '"] .note-scope a'), comment.id, 10000); + check('the thread opens on it, its page above it', !!opened); + 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✅ web-scoped comments show with their page, and an "other" report stays off the author' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
