diff options
| author | Adam Soltys <[email protected]> | 2026-09-30 18:03:33 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-30 18:03:33 +0000 |
| commit | 0570f48910ddc4ea10d27fb8ab7992fbd013a0b7 (patch) | |
| tree | dbc1e5ece94bc71fcd8bb7e89f61043933c1992c /tools | |
| parent | 95eaf11d116260b101c81346ba41810aacf49a7d (diff) | |
| parent | cfeacb1a7a10766565ed3eaab13154096478c837 (diff) | |
| download | coinosv3-0570f48910ddc4ea10d27fb8ab7992fbd013a0b7.tar.gz coinosv3-0570f48910ddc4ea10d27fb8ab7992fbd013a0b7.zip | |
Merge remote-tracking branch 'cs/prod'
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/ark/send-failure-test.js | 23 | ||||
| -rw-r--r-- | tools/avatar-profile-test.js | 124 | ||||
| -rw-r--r-- | tools/comments-test.js | 99 | ||||
| -rw-r--r-- | tools/compose-preview-test.js | 6 | ||||
| -rw-r--r-- | tools/composer-media-test.js | 103 | ||||
| -rw-r--r-- | tools/feed-bottom-nav-test.js | 99 | ||||
| -rw-r--r-- | tools/feed-nav-destination-test.js | 18 | ||||
| -rw-r--r-- | tools/feed-new-posts-test.js | 74 | ||||
| -rw-r--r-- | tools/feed-wallet-back-test.js | 25 | ||||
| -rw-r--r-- | tools/image-tap-test.js | 70 | ||||
| -rw-r--r-- | tools/image-zoom-test.js | 84 | ||||
| -rw-r--r-- | tools/media-upload-test.js | 61 | ||||
| -rw-r--r-- | tools/notif-note-cache-test.js | 128 | ||||
| -rw-r--r-- | tools/poll-test.js | 93 | ||||
| -rw-r--r-- | tools/post-actions-test.js | 2 | ||||
| -rw-r--r-- | tools/post-cancel-test.js | 20 | ||||
| -rw-r--r-- | tools/quote-card-test.js | 38 | ||||
| -rw-r--r-- | tools/room-faces-test.js | 88 | ||||
| -rw-r--r-- | tools/thread-cache-test.js | 19 | ||||
| -rw-r--r-- | tools/topic-depth-test.js | 36 | ||||
| -rw-r--r-- | tools/upload-progress-test.js | 37 | ||||
| -rw-r--r-- | tools/video-compress-test.js | 126 |
22 files changed, 1354 insertions, 19 deletions
diff --git a/tools/ark/send-failure-test.js b/tools/ark/send-failure-test.js index e3120f8..e3d64bf 100644 --- a/tools/ark/send-failure-test.js +++ b/tools/ark/send-failure-test.js @@ -10,11 +10,12 @@ const send = await import('../../src/ark/send.js'); let cosigns = 0; const badId = 'a'.repeat(64) + ':0'; const error = new GrpcError(3, `bad user input: vtxo ${badId} expired at height 100 (tip = 200)`, 'RequestArkoorCosign'); +let nextError = error; mock.module('../../src/ark/send.js', () => ({ ...send, registerVtxoTransactions: async () => {}, buildArkoorSend: () => ({}), - cosignPackageWithServer: async () => { cosigns++; throw error; }, + cosignPackageWithServer: async () => { cosigns++; throw nextError; }, })); const { ArkManager } = await import('../../src/ark/manager.js'); const key = n => secp256k1.getPublicKey(new Uint8Array(32).fill(n), true); @@ -32,17 +33,25 @@ Object.defineProperty(mgr, 'chain', { value: { tipHeight: async () => 200 } }); mgr._decoded = () => ({ _raw: { bytes: new Uint8Array() } }); mgr._keyForVtxo = () => ({}); -await assert.rejects(mgr.send(dest, 181), /expired at height 100/); +// the refusal marks the coin and the send goes again on what is left — +// here nothing covers 181, so the user hears about the shortfall +await assert.rejects(mgr.send(dest, 181), /1 sat expired and unusable/); +assert.equal(cosigns, 1, 'the retry does not reuse the rejected coin'); assert.equal(mgr.state.actions[0].step, 'failed'); assert.ok(mgr.state.vtxos.every(v => v.state === 'spendable'), 'atomic rejection does not consume either input'); assert.deepEqual(mgr.balance(), { spendableSat: 180, pendingSat: 0, boardingSat: 0, expiredSat: 1 }); +assert.equal(mgr.state.movements.length, 0, 'the retried attempt leaves no failed row'); +assert.equal(mgr.pendingActions().length, 0, 'a definitive rejection is not driven again'); +console.log('✓ an expired-input rejection retries without the coin and preserves unspent inputs'); + +// any other refusal still surfaces at once and is recorded +nextError = new GrpcError(3, 'bad user input: something else', 'RequestArkoorCosign'); +await assert.rejects(mgr.send(dest, 180), /something else/); +assert.equal(cosigns, 2, 'no retry for other refusals'); assert.equal(mgr.state.movements[0].status, 'failed'); assert.equal(mgr.state.movements[0].to, dest); -assert.equal(mgr.pendingActions().length, 0, 'a definitive rejection is not automatically retried'); -console.log('✓ a rejected send throws, preserves unspent inputs, and records failure'); - -await assert.rejects(mgr.send(dest, 181), /1 sat expired and unusable/); -assert.equal(cosigns, 1, 'send-all does not reuse the rejected coin'); +console.log('✓ other rejections throw once and record failure'); +nextError = error; assert.equal(mgr._selectInputs(180, 200)[0].amountSat, 180); assert.equal(mgr._expired({ amountSat: 1, expiryHeight: 100 }, 200), false, 'other dust retains its existing policy'); delete mgr.state.vtxos[0].expiryRejected; diff --git a/tools/avatar-profile-test.js b/tools/avatar-profile-test.js new file mode 100644 index 0000000..293d7c1 --- /dev/null +++ b/tools/avatar-profile-test.js @@ -0,0 +1,124 @@ +// Tapping a face in the feed opens that person's profile AT ITS TOP — not +// the thread the row would open, and not the profile scrolled down to +// wherever the reader was in the feed. Back returns to the same place. +// +// Run: bun tools/avatar-profile-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { cacheKeyFor } from '../src/wallet.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5271, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const 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; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5271/', { 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 A = 'a'.repeat(63) + '9', B = 'b'.repeat(63) + '7'; + await page.evaluate(([k, a, b]) => { + const now = Math.floor(Date.now() / 1000); + const ns = Array.from({ length: 30 }, (_, i) => ({ + id: (i + 16).toString(16).padStart(64, 'c'), pubkey: i % 2 ? b : a, kind: 1, created_at: now - i * 60, + content: 'post number ' + i + '\n\n' + 'a few lines so rows have height. '.repeat(4), tags: [], + })); + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a], ['p', b]], c: '', at: now })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Alice Poster', t: Date.now() }, [b]: { name: 'Bob Poster', about: 'bob bio here', t: Date.now() } })); + }, [base, A, B]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await waitText('post number 15', 15000); + await sleep(1500); + + // scroll down to a row well into the feed, then tap its face + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('post number 13')); r.scrollIntoView({ block: 'center' }); }); + await sleep(700); + const feedY = await page.evaluate(() => window.scrollY); + const pt = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('post number 13')).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); + await page.touchscreen.tap(pt.x, pt.y); + await sleep(1200); + const after = await page.evaluate(() => ({ y: window.scrollY, text: document.body.innerText.slice(0, 400), thread: !!document.querySelector('.note-thread, .thread-page') })); + if (process.env.DBG) console.log(feedY, JSON.stringify(after)); + check('the tap opens the profile', /Bob Poster/.test(after.text) && /bob bio here/.test(after.text), after.text.replace(/\s+/g, ' ').slice(0, 80)); + check('...at its top', after.y < 5, 'scrollY ' + after.y + ' (feed was at ' + feedY + ')'); + + await page.goBack().catch(() => {}); + await sleep(1200); + let back = await page.evaluate(() => ({ y: window.scrollY, feed: !!document.querySelector('.notes-feed') && document.body.innerText.includes('post number 13') })); + if (!back.feed) { + await page.evaluate(() => { const b = document.querySelector('.back, [aria-label="Back"]'); if (b) b.click(); }); + await sleep(1200); + back = await page.evaluate(() => ({ y: window.scrollY, feed: !!document.querySelector('.notes-feed') })); + } + check('back returns to the feed where the reader was', back.feed && Math.abs(back.y - feedY) < 60, 'scrollY ' + back.y + ' vs ' + feedY); + + // a post opened into its thread: Back lands where the reader was + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('post number 13')); r.scrollIntoView({ block: 'center' }); }); + await sleep(700); + const y0 = await page.evaluate(() => window.scrollY); + await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('post number 13')).querySelector('.note-text').click()); + await sleep(1500); + const inThread = await page.evaluate(() => !!document.querySelector('.thread-page')); + await page.goBack(); await sleep(1500); + const y1 = await page.evaluate(() => window.scrollY); + check('back from a post returns to the same place in the feed', inThread && y0 > 500 && Math.abs(y1 - y0) < 60, 'scrollY ' + y1 + ' vs ' + y0); + + // "Their timeline" from the profile: its chip names it (it was a blank pill) + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('Bob Poster')); r.click(); }); + await sleep(1000); + await click('their timeline'); + await sleep(1500); + const chip = await page.evaluate(() => document.querySelector('.feed-chip.on')?.textContent || null); + check('someone’s timeline has a labelled chip', !!chip && /Bob Poster/.test(chip), JSON.stringify(chip)); + + // ‹ on their timeline goes back to their profile, not the chat list + await page.evaluate(() => document.querySelector('.feed-page .chat-back').click()); + await sleep(1200); + const backTo = await page.evaluate(() => ({ profile: /bob bio here/.test(document.body.innerText), chats: /Communities/.test(document.body.innerText) && !document.querySelector('.feed-page') })); + check('‹ on someone’s timeline goes back to their profile', backTo.profile && !backTo.chats, JSON.stringify(backTo)); + // ...and from their timeline, the Feed tab is your own feed again + await click('their timeline'); + await sleep(1500); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await sleep(1200); + const mine = await page.evaluate(() => document.querySelector('.feed-chip.on')?.textContent || null); + check('the Feed tab brings back your own feed', mine === 'Following', JSON.stringify(mine)); + // back onto their timeline for the save below + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('Bob Poster')); r.click(); }); + await sleep(1000); + await click('their timeline'); + await sleep(1500); + + // ⋯ → Save feed: straight into the chips, no editor listing their follows + await page.evaluate((l) => document.querySelector('button[aria-label="' + l + '"]').click(), 'Feed options'); + await sleep(500); + const saved = await click('save feed'); + await sleep(1000); + const savedState = await page.evaluate(() => ({ editor: /New feed/.test(document.body.innerText), chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent), on: document.querySelector('.feed-chip.on')?.textContent })); + check('“Save feed” on a timeline saves it without the editor', saved && !savedState.editor && /Bob Poster/.test(savedState.on || ''), JSON.stringify(savedState)); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await sleep(2000); + const kept = await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent)); + check('...and it is still among the feeds after a reload', kept.some((c) => /(Bob Poster|npub1hwam).*’s timeline/.test(c)), JSON.stringify(kept)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a face opens its profile at the top' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/comments-test.js b/tools/comments-test.js new file mode 100644 index 0000000..01d47d5 --- /dev/null +++ b/tools/comments-test.js @@ -0,0 +1,99 @@ +// NIP-22 comments (kind 1111): a thread rooted in something other than a +// kind 1 note (here a real long-form article, kind 30023) shows its +// comments, and a reply to it — or to one of its comments — goes out as a +// kind 1111 with the root's E/K/P and the parent's e/k/p. Outgoing events +// are caught at the socket; nothing is published. +// +// Run: bun tools/comments-test.js +import puppeteer from 'puppeteer-core'; +import { SimplePool } from 'nostr-tools/pool'; +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 ROOT = 'b615961519481cffc69af3e6ba9ae45fc236c251f21d327d243804dc94a77026'; +const NEVENT = 'nevent1qvzqqqr4gupzq0mhp4ja8fmy48zuk5p6uy37vtk8tx9dqdwcxm32sy8nsaa8gkeyqqstv9vkz5v5s88lc6d08e46ntj9ls3kcfgly8fj05jrspxujjnhqfsede0ss'; +const pool = new SimplePool(); +const R = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.nostr.band', 'wss://nostr.wine']; +let root = null, comments = []; +for (let i = 0; i < 3 && !(root && comments.length); i++) { + root ||= (await pool.querySync(R, { ids: [ROOT] }, { maxWait: 8000 }))[0]; + if (!comments.length) comments = await pool.querySync(R, { kinds: [1111], '#E': [ROOT] }, { maxWait: 5000 }); +} +if (!root || !comments.length) { console.log(' ✗ the article or its comments are gone from the relays', !!root, comments.length); process.exit(1); } +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5283, fetch: () => 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.__sent = []; + window.__all = []; + const send = WebSocket.prototype.send; + WebSocket.prototype.send = function (d) { + window.__all.push(this.url + ' ' + String(d).slice(0, 60)); + if (typeof d === 'string' && d.startsWith('["EVENT"')) { // every event: nothing leaves the test + const ev = JSON.parse(d)[1]; + window.__sent.push(ev); + // answer as a relay would, so the app counts it published + setTimeout(() => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', ev.id, true, '']) })), 50); + return; + } + return send.call(this, d); + }; +}); +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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +const reply = async (rowFind, text) => { + await page.evaluate((f) => { const row = new Function('return ' + f)()(); row.querySelector('button[aria-label="Reply"]').click(); }, rowFind.toString()); + await waitFor(() => !!document.querySelector('.thread-reply-input'), 5000); + if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT + text.length + '.png' }); + await page.focus('.thread-reply-input'); + await page.keyboard.type(text); + if (process.env.DBG) console.log('typed:', await page.evaluate(() => ({ boxes: document.querySelectorAll('.thread-reply-input').length, v: document.querySelector('.thread-reply-input')?.value, sendDisabled: document.querySelector('.thread-reply-send')?.disabled, active: document.activeElement?.className }))); + await page.evaluate(() => document.querySelector('.thread-reply-send').click()); + await sleep(300); + if (process.env.DBG && text.startsWith('a reply')) console.log('ALL', await page.evaluate(() => window.__all.slice(-25))); + if (process.env.DBG) console.log('after send:', await page.evaluate(() => ({ boxes: document.querySelectorAll('.thread-reply-input').length, toast: document.querySelector('.toast.show')?.textContent }))); + // one event per relay it goes to: pick the one carrying this text + const mine = 'return window.__sent.find((e) => e.content.endsWith(' + JSON.stringify(text) + '))'; + await waitFor(new Function('return !!(() => { ' + mine + ' })()'), 20000); + return page.evaluate(new Function(mine)); +}; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5283/', { 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.goto('http://localhost:5283/' + NEVENT, { waitUntil: 'domcontentloaded' }); + const first = comments.sort((a, b) => a.created_at - b.created_at)[0]; + const shown = await waitFor(new Function('return document.querySelector(".thread-page")?.innerText.includes(' + JSON.stringify(first.content.slice(0, 20)) + ')'), 25000); + check('the article opens with its NIP-22 comments', shown, JSON.stringify(first.content.slice(0, 40))); + const rows = await page.evaluate(() => document.querySelectorAll('.thread-page [data-zap-post]').length); + check('...each as a row of the thread', rows >= 1 + Math.min(comments.length, 2), rows + ' rows for ' + comments.length + ' comments'); + + const ev = await reply(() => document.querySelector('.thread-page [data-zap-post]'), 'a comment on the article'); + const tag = (e, n) => (e?.tags || []).filter((x) => x[0] === n).map((x) => x[1]); + check('a reply to the article is a kind 1111 comment', ev?.kind === 1111, 'kind ' + ev?.kind); + check('...with the root as E/K/P', tag(ev, 'E')[0] === ROOT && tag(ev, 'K')[0] === '30023' && tag(ev, 'P')[0] === root.pubkey, JSON.stringify(ev?.tags)); + check('...and its address as A', tag(ev, 'A')[0] === '30023:' + root.pubkey + ':' + tag(root, 'd')[0]); + check('...and the article as the parent (e/k/p)', tag(ev, 'e')[0] === ROOT && tag(ev, 'k')[0] === '30023' && tag(ev, 'p')[0] === root.pubkey); + + await page.evaluate(() => document.querySelector('.thread-reply-cancel')?.click()); + const onScreen = await page.evaluate(() => [...document.querySelectorAll('.thread-page [data-zap-post]')].filter((r) => r.querySelector('button[aria-label="Reply"]')).map((r) => r.getAttribute('data-zap-post'))); + const target = comments.find((c) => onScreen.includes(c.id)); + if (process.env.DBG) console.log('rows with Reply', onScreen.length, 'comments', comments.map((c) => c.id.slice(0, 8)), 'shown', onScreen.map((x) => x.slice(0, 8))); + const cid = target?.id; + const ev2 = await reply(new Function('return () => document.querySelector(\'.thread-page [data-zap-post="' + cid + '"]\')')(), 'a reply to a comment'); + if (process.env.DBG) console.log(await page.evaluate((cid) => ({ sent: window.__sent.map((e) => e.kind + ':' + e.content.slice(0, 30)), row: !!document.querySelector('.thread-page [data-zap-post="' + cid + '"]'), box: document.querySelector('.thread-reply-input')?.value, toast: document.querySelector('.toast')?.textContent, sending: !!document.querySelector('.thread-reply-send .spinner') }), cid)); + if (process.env.DBG) console.log('EV2', JSON.stringify(ev2)?.slice(0, 200), await page.evaluate(() => window.__sent.filter((e) => e.kind === 1111).map((e) => JSON.stringify(e.content)))); + check('a reply to a comment is a kind 1111 too', ev2?.kind === 1111 && tag(ev2, 'E')[0] === ROOT, 'kind ' + ev2?.kind); + check('...its parent the comment (e/k/p)', tag(ev2, 'e')[0] === cid && tag(ev2, 'k')[0] === '1111' && tag(ev2, 'p')[0] === target?.pubkey, JSON.stringify(ev2?.tags.filter((x) => /^[ekp]$/.test(x[0])))); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ comments read and write as NIP-22' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/compose-preview-test.js b/tools/compose-preview-test.js index c2f35e6..2138ea6 100644 --- a/tools/compose-preview-test.js +++ b/tools/compose-preview-test.js @@ -66,9 +66,9 @@ try { await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); await sleep(2500); const opened = await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((n) => (n.getAttribute('aria-label') || n.textContent).trim().toLowerCase() === 'new post'); if (b) { b.click(); return true; } return false; }); - check('the composer opens', opened && !!(await page.$('textarea'))); + check('the composer opens', opened && !!(await page.$('coinos-text'))); - await type('textarea', DRAFT); + await type('coinos-text', DRAFT); await sleep(400); check('no preview until it is asked for', !(await page.$('.draft-preview'))); @@ -92,7 +92,7 @@ try { shown ? JSON.stringify(shown.text.slice(0, 60)) : ''); // live: keep typing and the preview follows - await type('textarea', DRAFT + ' plus a word'); + await type('coinos-text', DRAFT + ' plus a word'); await sleep(900); check('it follows the draft as you type', /plus a word/.test(await page.evaluate(() => (document.querySelector('.draft-preview') || {}).innerText || ''))); diff --git a/tools/composer-media-test.js b/tools/composer-media-test.js new file mode 100644 index 0000000..9b2b41c --- /dev/null +++ b/tools/composer-media-test.js @@ -0,0 +1,103 @@ +// The reply box is <coinos-text>, a contenteditable field — the kind +// Android keyboards offer GIFs and images to (a <textarea> gets the GIF key +// greyed out). It must still type like a textarea, keep pasted text plain, +// and send a pasted/keyboard picture through the same upload as the +// paperclip, its URL landing in the draft. +// +// Run: bun tools/composer-media-test.js +import puppeteer from 'puppeteer-core'; +import { cacheKeyFor } from '../src/wallet.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5279, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const uploads = []; +await page.setRequestInterception(true); +page.on('request', (req) => { + const u = req.url(); + if (/nostr\.download|nostr\.build/.test(u) && req.method() === 'PUT') { + uploads.push(u); + return req.respond({ status: 200, contentType: 'application/json', headers: { 'access-control-allow-origin': '*' }, + body: JSON.stringify({ url: 'https://nostr.download/' + 'f'.repeat(64) + '.gif', sha256: 'f'.repeat(64), size: 10, type: 'image/gif' }) }); + } + if (/nostr\.download|nostr\.build/.test(u) && req.method() === 'OPTIONS') { + return req.respond({ status: 204, headers: { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD, GET', 'access-control-allow-headers': '*' } }); + } + req.continue(); +}); +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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5279/', { 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 A = 'a'.repeat(63) + '9'; + await page.evaluate(([k, a]) => { + const now = Math.floor(Date.now() / 1000); + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a]], c: '', at: now })); + localStorage.setItem(k + ':feedNotes', JSON.stringify([{ id: 'c'.repeat(64), pubkey: a, kind: 1, created_at: now, content: 'a post to answer', tags: [] }])); + localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Poster', t: Date.now() } })); + }, [base, A]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await waitText('a post to answer', 20000); + await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('a post to answer')).querySelector('button[aria-label="Reply"]').click()); + check('the reply box appears', await waitFor(() => !!document.querySelector('.thread-reply-input'), 10000)); + + const box = await page.evaluate(() => { const b = document.querySelector('.thread-reply-input'); return { tag: b.tagName, ce: b.getAttribute('contenteditable'), empty: b.hasAttribute('data-empty') }; }); + check('it is the contenteditable field keyboards offer pictures to', box.tag === 'COINOS-TEXT' && box.ce === 'true', JSON.stringify(box)); + check('...showing its placeholder while empty', box.empty); + + await page.focus('.thread-reply-input'); + await page.keyboard.type('hello'); + await page.keyboard.down('Shift'); await page.keyboard.press('Enter'); await page.keyboard.up('Shift'); + await page.keyboard.type('world'); + let v = await page.evaluate(() => document.querySelector('.thread-reply-input').value); + check('it types like a textarea, Shift+Enter a newline', v === 'hello\nworld', JSON.stringify(v)); + + // pasted rich text arrives as plain text + await page.evaluate(() => { + const dt = new DataTransfer(); + dt.setData('text/html', '<b style="color:red">bold</b> <a href="x">link</a>'); + dt.setData('text/plain', ' bold link'); + document.querySelector('.thread-reply-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + }); + v = await page.evaluate(() => ({ v: document.querySelector('.thread-reply-input').value, html: document.querySelector('.thread-reply-input').innerHTML })); + check('pasted formatting is dropped, the text kept', v.v === 'hello\nworld bold link' && !/<b|<a /.test(v.html), JSON.stringify(v)); + + // a picture from the keyboard/clipboard goes up like the paperclip's + await page.evaluate(() => { + const dt = new DataTransfer(); + const gif = Uint8Array.from(atob('R0lGODlhAQABAAAAACw='), (c) => c.charCodeAt(0)); + dt.items.add(new File([gif], 'wave.gif', { type: 'image/gif' })); + document.querySelector('.thread-reply-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + }); + const landed = await waitFor(() => /nostr\.download\/f{64}\.gif/.test(document.querySelector('.thread-reply-input')?.value || ''), 15000); + check('a pasted GIF is uploaded and its URL lands in the reply', landed && uploads.length === 1, + JSON.stringify(await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)) + ' uploads=' + uploads.length); + check('...and the preview shows it', await waitFor(() => !!document.querySelector('.draft-preview img, .draft-preview .note-img, .draft-preview a'), 5000)); + // the upload re-rendered the page: the field must still be editable + await page.focus('.thread-reply-input'); + await page.evaluate(() => { const x = document.querySelector('.thread-reply-input'); x.setSelectionRange(x.value.length); }); + await page.keyboard.type(' ok'); + const after = await page.evaluate(() => ({ v: document.querySelector('.thread-reply-input').value, ce: document.querySelector('.thread-reply-input').getAttribute('contenteditable') })); + check('after a repaint it still takes typing', after.ce === 'true' && after.v.endsWith('.gif ok'), JSON.stringify(after).slice(-60)); + const noImg = await page.evaluate(() => !document.querySelector('.thread-reply-input img')); + check('no image element is left inside the text', noImg); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the reply box takes pictures' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/feed-bottom-nav-test.js b/tools/feed-bottom-nav-test.js new file mode 100644 index 0000000..4354e97 --- /dev/null +++ b/tools/feed-bottom-nav-test.js @@ -0,0 +1,99 @@ +// Real feed/navigation views with local posts and no relay traffic. +// Run: bun tools/app-bottom-nav-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');")); +const entry = ` +import { messagesFeature } from './src/features/messages.js'; +${dom} +const author = 'a'.repeat(64); +const ui = { screen: 'wallet', chatOpen: true, msgView: 'feed' }; +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()]); } +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; + await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i, + content: 'A post in the feed. Scroll to keep Home, messages and notifications within reach. '.repeat(3) }))); + render(); window.scrollTo(0,0); +}, unseen() { feature.testUnseen(); render(); } }; +await test.feed(); +`; +const bundle = await Bun.build({ entrypoints: ['nav-test-entry'], target: 'browser', plugins: [{ name: 'nav-test', setup(build) { + build.onResolve({filter:/^nav-test-entry$/},()=>({path:'entry',namespace:'nav-test'})); + build.onLoad({filter:/.*/,namespace:'nav-test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); + build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20 }); }, testUnseen: () => { feed.unseen = 2; }, testChats: (n, m) => { stCache = null; if (m != null) st().communities = Array.from({ length: m }, (_, i) => ({ ...COMMUNITY, community_id: (i + 1).toString(16).padStart(64, 'd'), name: 'Room ' + i })); threads.clear(); for (let i = 0; i < n; i++) { const peer = (i + 1).toString(16).padStart(64, 'c'); threads.set(peer, new Map([['r' + i, { rumor: { id: 'r' + i, kind: 14, pubkey: peer, tags: [], content: 'hi ' + i, created_at: 1700000000 - i }, mine: false }]])); } },")})); + build.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) + .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') + .replace(/export function subscribeOn\(([^)]*)\) \{/,'export function subscribeOn($1) { return () => {};')})); +} }] }); +assert(bundle.success,bundle.logs.join('\n')); +const html=`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const remote = !!process.env.BROWSER_URL; +const server=Bun.serve({port:remote ? 5303 : 0,fetch:()=>new Response(html,{headers:{'content-type':'text/html'}})}); +const browser=remote ? await puppeteer.connect({browserURL:process.env.BROWSER_URL}) : await puppeteer.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox']}); +const page=await browser.newPage(), errors=[]; +page.on('pageerror', e=>errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort()); +const scroll=async()=>{ await page.evaluate(()=>window.scrollTo(0,700)); await page.waitForFunction(()=>scrollY > 0 && document.querySelector('.app-bottom-nav')?.getBoundingClientRect().height > 0); }; +try { + await page.setViewport({width:390,height:844,isMobile:true,hasTouch:true}); + await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]); + await page.goto(server.url.href); + await page.waitForSelector('.note-post', {timeout:5000}); + assert(await page.$eval('.app-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'visible at top without scrolling'); + await scroll(); + assert.deepEqual(await page.$$eval('.app-nav-button',els=>els.map(e=>e.textContent)),['Feed','Messages','Notifications','Wallet']); + assert(await page.$eval('.app-bottom-nav',e=>Math.abs(e.getBoundingClientRect().bottom-innerHeight)<1),'fixed at viewport bottom'); + await page.evaluate(()=>test.unseen()); + assert(await page.evaluate(()=>document.querySelector('.feed-new-pill').getBoundingClientRect().bottom < document.querySelector('.app-bottom-nav').getBoundingClientRect().top),'new posts pill stays above navigation'); + await page.screenshot({path:'/tmp/app-bottom-nav-mobile.png'}); + await page.tap('.app-nav-button:nth-child(1)'); + await page.waitForFunction(()=>scrollY===0 && !document.querySelector('.app-bottom-nav').hidden); + assert.equal(await page.evaluate(()=>test.ui.msgView),'feed','Feed stays in feed'); + await scroll(); await page.tap('.app-nav-button:nth-child(2)'); + await page.waitForFunction(()=>test.ui.msgView==='home' && !!document.querySelector('.app-bottom-nav')); + assert((await page.evaluate(()=>document.body.innerText)).includes('Messages'),'chat list renders'); + assert(await page.$eval('.app-nav-button:nth-child(2)',e=>e.getAttribute('aria-current')==='page'),'messages marked active'); + const rows=()=>page.evaluate(()=>{const ls=[...document.querySelectorAll('.chat-page > .list')];return ls.map(l=>l.querySelectorAll('.chat-thread-row').length);}); + const links=()=>page.$$eval('.chat-page > button.linklike',els=>els.map(e=>e.textContent)); + await page.evaluate(()=>{const m={};const orig=test.wallet.loadFeatureState;test.wallet.loadFeatureState=(k,d)=>k==='messages'?m:orig(k,d);test.feature.testChats(4,4);test.render();}); + await page.waitForFunction(()=>document.querySelectorAll('.chat-page .chat-thread-row').length>=8); + assert.deepEqual(await rows(),[4,4]); assert.deepEqual(await links(),['Show all 5 communities'],'four DMs show whole; five communities are gated'); + await page.evaluate(()=>{test.feature.testChats(9);test.render();}); + assert.deepEqual(await rows(),[4,4]); assert.deepEqual(await links(),['Show all 9 conversations','Show all 5 communities']); + await page.$$eval('.chat-page > button.linklike',els=>els.forEach(e=>e.click())); + assert.deepEqual(await rows(),[9,5]); assert.deepEqual(await links(),['Show fewer','Show fewer']); + await page.$$eval('.chat-page > button.linklike',els=>els.forEach(e=>e.click())); + assert.deepEqual(await rows(),[4,4],'show fewer folds both lists back'); + await page.evaluate(()=>{test.ui.msgView='dm';test.ui.msgPeer='b'.repeat(64);test.render();}); + await page.waitForSelector('.chat-card'); + assert(await page.evaluate(()=>document.querySelector('.chat-card').getBoundingClientRect().bottom <= document.querySelector('.app-bottom-nav').getBoundingClientRect().top),'conversation and composer fit above bottom nav'); + await page.screenshot({path:'/tmp/app-nav-chat.png'}); + await page.tap('.app-nav-button:nth-child(3)'); + await page.waitForFunction(()=>test.ui.msgView==='notifs' && !!document.querySelector('.app-bottom-nav')); + assert((await page.evaluate(()=>document.body.innerText)).includes('Notifications'),'notifications render'); + await page.tap('.app-nav-button:nth-child(1)'); await scroll(); await page.tap('.note-text'); + await page.waitForFunction(()=>!!test.ui.noteThread && !!document.querySelector('.app-bottom-nav')); + await page.tap('.app-nav-button:nth-child(1)'); + assert(await page.$eval('.app-bottom-nav',e=>!e.hidden && e.getBoundingClientRect().height>0),'returning to feed restores nav immediately without a scroll'); + await page.evaluate(()=>{test.ui.tab='settings';}); + await page.tap('.app-nav-button:nth-child(4)'); + await page.waitForSelector('.wallet-screen'); + assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.tab==='receive' && !!document.querySelector('.app-bottom-nav')),'Wallet opens main wallet instead of old settings tab'); + await page.tap('.app-nav-button:nth-child(1)'); + await page.waitForSelector('.feed-page'); + await page.setViewport({width:320,height:844,isMobile:true,hasTouch:true}); + assert(await page.$eval('.app-bottom-nav',e=>e.scrollWidth<=e.clientWidth),'four buttons fit a narrow phone'); + await page.screenshot({path:'/tmp/app-bottom-nav-always-mobile.png'}); + await page.evaluate(()=>{test.wallet.xpub=null;test.render();}); await scroll(); + await page.tap('.app-nav-button:nth-child(2)'); + assert(await page.evaluate(()=>!test.ui.chatOpen && test.ui.msgView===null),'visitor reaches sign-in screen'); + assert.deepEqual(errors,[]); + console.log('✓ Global nav works across feed, chat list, conversations, notifications, threads and wallet; Feed restores the feed; composer remains above nav; visitor entry and narrow layout work; DMs and communities cap at four behind Show all'); +} finally { await page.close(); if (remote) await browser.disconnect(); else await browser.close(); server.stop(true); } diff --git a/tools/feed-nav-destination-test.js b/tools/feed-nav-destination-test.js new file mode 100644 index 0000000..b14a124 --- /dev/null +++ b/tools/feed-nav-destination-test.js @@ -0,0 +1,18 @@ +// The Feed destination and the new-posts pill deliberately have different +// navigation semantics. Keep this small source-level guard beside the fuller +// browser test for the pill's landing position. +import assert from 'node:assert/strict'; + +const source = await Bun.file('src/features/messages.js').text(); +const navStart = source.indexOf("button('feed', t('feedTitle')"); +const navEnd = source.indexOf("button('messages',", navStart); +const feedButton = source.slice(navStart, navEnd); +assert(navStart > 0 && navEnd > navStart, 'Feed bottom-nav handler exists'); +assert(feedButton.includes('glideToTop()'), 'Feed bottom-nav button returns to the top'); +assert(!feedButton.includes('jumpToNew()'), 'Feed bottom-nav button does not jump to unread posts'); + +const pillStart = source.indexOf("class: 'feed-new-pill'"); +const pill = source.slice(pillStart, source.indexOf('})', pillStart) + 2); +assert(pillStart > 0 && pill.includes('jumpToNew()'), 'new-posts pill jumps to the first unread arrival'); + +console.log('✓ Feed tab goes to the top; only the new-posts pill jumps to unread posts'); diff --git a/tools/feed-new-posts-test.js b/tools/feed-new-posts-test.js new file mode 100644 index 0000000..c90f0d5 --- /dev/null +++ b/tools/feed-new-posts-test.js @@ -0,0 +1,74 @@ +// New posts arriving mid-read: the pill lands on the OLDEST new post, and +// each new post keeps a tint until it has been in view a couple of seconds. +// Run: bun tools/feed-new-posts-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');")); +const entry = ` +import { messagesFeature } from './src/features/messages.js'; +${dom} +const author = 'a'.repeat(64); +const ui = { screen: 'wallet', chatOpen: true, msgView: 'feed' }; +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()]); } +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; + await feature.testSeed(Array.from({length:20}, (_, i) => ({ id: i.toString(16).padStart(64,'0'), pubkey:author, kind:1, tags:[], created_at:Math.floor(Date.now()/1000)-i, + content: 'A post in the feed. Scroll to keep Home, messages and notifications within reach. '.repeat(3) }))); + render(); window.scrollTo(0,0); +}, unseen() { feature.testUnseen(); render(); }, arrive(n) { const now = Math.floor(Date.now()/1000); feature.testArrive(Array.from({length:n}, (_, i) => ({ id: 'f' + i.toString(16).padStart(63,'0'), pubkey:author, kind:1, tags:[], created_at: now + 100 - i, content: 'Brand new post number ' + i + '. '.repeat(1) + 'Fresh words. '.repeat(20) }))); } }; +await test.feed(); +`; +const bundle = await Bun.build({ entrypoints: ['nav-test-entry'], target: 'browser', plugins: [{ name: 'nav-test', setup(build) { + build.onResolve({filter:/^nav-test-entry$/},()=>({path:'entry',namespace:'nav-test'})); + build.onLoad({filter:/.*/,namespace:'nav-test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()})); + build.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testSeed: async (notes) => { const c = feedNow(); await c.boot; Object.assign(c, { notes, status: 'ready', booting: false, end: true, shown: 20 }); }, testUnseen: () => { feed.unseen = 2; }, testArrive: (notes) => admitFeed(notes, feed), testChats: (n, m) => { stCache = null; if (m != null) st().communities = Array.from({ length: m }, (_, i) => ({ ...COMMUNITY, community_id: (i + 1).toString(16).padStart(64, 'd'), name: 'Room ' + i })); threads.clear(); for (let i = 0; i < n; i++) { const peer = (i + 1).toString(16).padStart(64, 'c'); threads.set(peer, new Map([['r' + i, { rumor: { id: 'r' + i, kind: 14, pubkey: peer, tags: [], content: 'hi ' + i, created_at: 1700000000 - i }, mine: false }]])); } },")})); + build.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()) + .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') + .replace(/export function subscribeOn\(([^)]*)\) \{/,'export function subscribeOn($1) { return () => {};')})); +} }] }); +assert(bundle.success,bundle.logs.join('\n')); +const html=`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await 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(), errors=[]; +page.on('pageerror', e=>errors.push(e.message)); +await page.setRequestInterception(true); +page.on('request',r=>r.url().startsWith(server.url.origin)?r.continue():r.abort()); +const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); +const top=(id)=>page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');return r?Math.round(r.getBoundingClientRect().top):null;},id); +const fresh=()=>page.$$eval('.notes-feed > .row.note-fresh',els=>els.map(e=>e.getAttribute('data-key').slice(0,2)+e.getAttribute('data-key').slice(-1))); +const F=(i)=>'f'+i.toString(16).padStart(63,'0'); +try { + await page.setViewport({width:390,height:844,isMobile:true,hasTouch:true}); + await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]); + await page.goto(server.url.href); + await page.waitForSelector('.note-post',{timeout:5000}); + const reading='6'.padStart(64,'0'); + await page.evaluate((id)=>{const r=document.querySelector('.notes-feed > [data-key="'+id+'"]');window.scrollTo(0,scrollY+r.getBoundingClientRect().top-100);},reading); + await sleep(300); + const before=await top(reading); + await page.evaluate(()=>test.arrive(3)); await sleep(300); + assert(Math.abs((await top(reading))-before)<=2,'the row being read stays put when posts arrive above'); + assert.equal(await page.$eval('.feed-new-pill .n',e=>e.textContent),'3'); + assert.deepEqual((await fresh()).length,3,'all three new posts wear the tint'); + await page.tap('.feed-new-pill'); await sleep(400); + const oldest=await top(F(2)); + assert(oldest>=0&&oldest<=20,'pill lands on the oldest new post, not the top: '+oldest); + assert(await page.evaluate(()=>scrollY)>0,'not scrolled all the way to the top'); + assert(!(await page.$('.feed-new-pill')),'pill is gone after the tap'); + await page.screenshot({path:'/tmp/feed-new-posts.png'}); + await sleep(2600); + assert(!(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(2)+'"]')),'the post you looked at loses its tint'); + assert(await page.$('.notes-feed > .row.note-fresh[data-key="'+F(0)+'"]'),'the newest, still offscreen above, keeps its tint'); + await page.evaluate(()=>window.scrollTo(0,0)); await sleep(2600); + assert.deepEqual(await fresh(),[],'back at the top, the rest fade once seen'); + assert.deepEqual(errors,[]); + console.log('✓ New posts: pill lands on the oldest new post; tints stay until each post has been in view ~2s; the reading row stays put'); +} finally { await page.close(); await browser.close(); server.stop(true); } diff --git a/tools/feed-wallet-back-test.js b/tools/feed-wallet-back-test.js new file mode 100644 index 0000000..1b0abe7 --- /dev/null +++ b/tools/feed-wallet-back-test.js @@ -0,0 +1,25 @@ +// Exercise the real app history: a public feed must survive Wallet -> Back. +// Run: bun tools/feed-wallet-back-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +import { buildHtml } from '../build.js'; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: req => new URL(req.url).pathname === '/api/feed' + ? new Response(null, { status: 404 }) : 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(); + 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()); + await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.app-bottom-nav', { visible: true }); + await page.tap('.app-nav-button:nth-child(4)'); + await page.waitForFunction(() => !document.querySelector('.app-bottom-nav')); + assert(!(await page.$('.app-bottom-nav')), 'no nav over the welcome screen'); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.app-bottom-nav', { visible: true, timeout: 5000 }); + assert(await page.$eval('.app-bottom-nav', e => !e.hidden && e.getBoundingClientRect().height > 0)); + assert.equal(await page.evaluate(() => scrollY), 0, 'visible on return without scrolling'); + console.log('✓ Real browser Back restores the public feed and visible navigation after Wallet'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/image-tap-test.js b/tools/image-tap-test.js new file mode 100644 index 0000000..c531bba --- /dev/null +++ b/tools/image-tap-test.js @@ -0,0 +1,70 @@ +// A tall picture in a feed post is fitted inside its box, leaving blank +// bands either side. A tap on a band opens the post (its thread); a tap on +// the picture opens the picture. +// +// Run: bun tools/image-tap-test.js +import puppeteer from 'puppeteer-core'; +import { cacheKeyFor } from '../src/wallet.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const png = '/tmp/coinos-image-tap-test.png'; +Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=800x1600', '-frames:v', '1', png]); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5281, fetch: (req) => new URL(req.url).pathname === '/tall.png' + ? new Response(Bun.file(png), { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }) + : new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const 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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5281/', { 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 A = 'a'.repeat(63) + '9'; + await page.evaluate(([k, a]) => { + const now = Math.floor(Date.now() / 1000); + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a]], c: '', at: now })); + localStorage.setItem(k + ':feedNotes', JSON.stringify([{ id: 'c'.repeat(64), pubkey: a, kind: 1, created_at: now, content: 'a tall one http://localhost:5281/tall.png', tags: [] }])); + localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Poster', t: Date.now() } })); + }, [base, A]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + check('the picture is in the post', await waitFor(() => { const i = document.querySelector('.notes-feed img.note-img'); return i && i.complete && i.naturalWidth > 0; }, 20000)); + // on the phone the box spans the column and the picture sits in its middle + // (object-fit: contain); desktop Chrome sizes the box to the picture, so + // widen it the way the phone lays it out + const widen = () => page.evaluate(() => { const i = document.querySelector('.notes-feed img.note-img'); i.style.width = '100%'; i.style.height = '320px'; }); + await widen(); + const r = await page.evaluate(() => { const b = document.querySelector('.notes-feed img.note-img').getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height }; }); + const drawn = r.h * 800 / 1600; // the picture's drawn width inside the box + check('the box is wider than the picture (blank bands)', r.w > drawn + 40, JSON.stringify({ box: Math.round(r.w), drawn: Math.round(drawn) })); + + const beside = process.env.DBG && await page.evaluate(([x, y]) => { const e = document.elementFromPoint(x, y); const chain = []; for (let n = e; n && chain.length < 5; n = n.parentElement) chain.push(n.tagName + '.' + (n.className || '').toString().split(' ').join('.') + (n.onclick ? '[click]' : '')); return chain; }, [r.x + 8, r.y + r.h / 2]); + await page.mouse.click(r.x + 8, r.y + r.h / 2); + await sleep(1200); + const band = await page.evaluate(() => ({ thread: !!document.querySelector('.thread-page'), lightbox: !!document.querySelector('.lightbox') })); + check('a tap on the blank band opens the post', band.thread && !band.lightbox, JSON.stringify(band)); + await page.goBack(); await sleep(1200); + + await widen(); + const r2 = await page.evaluate(() => { const b = document.querySelector('.notes-feed img.note-img').getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height }; }); + await page.mouse.click(r2.x + r2.w / 2, r2.y + r2.h / 2); + await sleep(800); + const pic = await page.evaluate(() => ({ thread: !!document.querySelector('.thread-page'), lightbox: !!document.querySelector('.lightbox') })); + check('a tap on the picture opens the picture', pic.lightbox && !pic.thread, JSON.stringify(pic)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ bands open the post, the picture opens the picture' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/image-zoom-test.js b/tools/image-zoom-test.js new file mode 100644 index 0000000..3be5798 --- /dev/null +++ b/tools/image-zoom-test.js @@ -0,0 +1,84 @@ +// The full-screen picture: pinch to zoom, pan when zoomed, double-tap to zoom +// and reset, a background render keeps the zoom, taps after a gesture don't +// close it, a plain tap still does, and the wheel zooms on a desktop. +// Run: bun tools/image-zoom-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; +const app = await Bun.file('src/app.js').text(); +const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf("const root = document.getElementById('app');")); +const viewer = app.slice(app.indexOf('function imageViewer()'), app.indexOf("if (typeof window !== 'undefined') {\n window.addEventListener('keydown'")); +const svg = encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><rect width="800" height="600" fill="#48a"/><circle cx="400" cy="300" r="120" fill="#fc3"/></svg>'); +const entry = ` +${dom} +${viewer} +const ui = { lightbox: 'data:image/svg+xml,${svg}' }; +const t = (k) => k; +const goBack = (fn) => { fn(); render(); }; +function render() { morphChildren(document.querySelector('#app'), ui.lightbox ? [imageViewer()] : []); } +window.test = { ui, render, open() { ui.lightbox = 'data:image/svg+xml,${svg}'; render(); } }; +render(); +`; +const bundle = await Bun.build({ entrypoints: ['zoom-entry'], target: 'browser', plugins: [{ name: 'zoom', setup(build) { + build.onResolve({ filter: /^zoom-entry$/ }, () => ({ path: 'entry', namespace: 'zoom' })); + build.onLoad({ filter: /.*/, namespace: 'zoom' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); +} }] }); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(), errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const zoom = () => page.evaluate(() => { const m = new DOMMatrix(getComputedStyle(document.querySelector('.lightbox img')).transform); return { s: +m.a.toFixed(2), x: Math.round(m.e), y: Math.round(m.f) }; }); +const open = () => page.evaluate(() => !!test.ui.lightbox && !!document.querySelector('.lightbox')); +try { + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.goto(server.url.href); + await page.waitForSelector('.lightbox img'); + const cdp = await page.target().createCDPSession(); + const touch = (type, points) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) }); + const cx = 195, cy = 422; + // pinch out around the centre + await touch('touchStart', [[cx - 40, cy], [cx + 40, cy]]); + for (let i = 1; i <= 8; i++) { await touch('touchMove', [[cx - 40 - i * 15, cy], [cx + 40 + i * 15, cy]]); await sleep(16); } + await touch('touchEnd', []); await sleep(350); + let z = await zoom(); + assert(z.s > 3.8 && z.s < 4.2, 'pinch zooms with the fingers (80px → 320px apart = 4×): ' + JSON.stringify(z)); + assert(await open(), 'a pinch does not close the picture'); + // a background render keeps the zoom + await page.evaluate(() => test.render()); await sleep(50); + assert.equal((await zoom()).s, z.s, 'a background render keeps the zoom'); + // one finger pans the zoomed picture, clamped to its edges + await touch('touchStart', [[cx, cy]]); + for (let i = 1; i <= 10; i++) { await touch('touchMove', [[cx + i * 20, cy + i * 5]]); await sleep(16); } + await touch('touchEnd', []); await sleep(350); + const panned = await zoom(); + assert(panned.x > z.x + 50, 'drag pans: ' + JSON.stringify(panned)); + const maxX = await page.evaluate((s) => (document.querySelector('.lightbox img').offsetWidth * s - innerWidth) / 2, panned.s); + assert(panned.x <= maxX + 1, 'pan is held to the picture edge'); + assert(await open(), 'a drag does not close the picture'); + // a tap while zoomed does not close; a double-tap resets + await page.touchscreen.tap(cx, cy); await sleep(400); + assert(await open(), 'a tap while zoomed does not close'); + await page.touchscreen.tap(cx, cy); await sleep(80); await page.touchscreen.tap(cx, cy); await sleep(350); + assert.equal((await zoom()).s, 1, 'double-tap resets the zoom'); + // double-tap at 1× zooms in at the spot, without closing + await page.touchscreen.tap(cx + 60, cy); await sleep(80); await page.touchscreen.tap(cx + 60, cy); await sleep(400); + z = await zoom(); + assert(z.s === 2.5 && z.x < 0, 'double-tap zooms in toward the tapped spot: ' + JSON.stringify(z)); + assert(await open(), 'a double-tap does not close'); + await page.touchscreen.tap(cx, cy); await sleep(80); await page.touchscreen.tap(cx, cy); await sleep(350); + // a single tap at 1× still closes + await page.touchscreen.tap(cx, cy); await sleep(450); + assert(!(await open()), 'a single tap closes the picture'); + // desktop: the wheel zooms toward the cursor + await page.setViewport({ width: 1280, height: 800 }); + await page.evaluate(() => test.open()); await page.waitForSelector('.lightbox img'); + await page.mouse.move(700, 400); + for (let i = 0; i < 5; i++) { await page.mouse.wheel({ deltaY: -120 }); await sleep(30); } + z = await zoom(); + assert(z.s > 1.5, 'the wheel zooms in: ' + JSON.stringify(z)); + assert(await open(), 'wheel zoom leaves the picture open'); + assert.deepEqual(errors, []); + console.log('✓ Image viewer: pinch zooms, drag pans within the edges, double-tap zooms and resets, renders keep the zoom, gestures never close it, a tap does, the wheel zooms on desktop'); +} finally { await browser.close(); server.stop(true); } diff --git a/tools/media-upload-test.js b/tools/media-upload-test.js new file mode 100644 index 0000000..e19cac7 --- /dev/null +++ b/tools/media-upload-test.js @@ -0,0 +1,61 @@ +import { uploadPublicMedia, MediaUploadError } from '../src/media-upload.js'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; + +let ok = true; +const check = (name, pass) => { console.log(`${pass ? '✓' : '✗'} ${name}`); if (!pass) ok = false; }; +const bytes = new TextEncoder().encode('phone video bytes'); +const file = new File([bytes], 'clip.mov', { type: 'video/quicktime' }); +let signed = null; +const sign = async (event) => (signed = { ...event, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) }); +const calls = []; +const fetcher = async (url, init) => { + if (init?.method === 'HEAD') return new Response('', { status: 200 }); // the reachability probe + calls.push({ url, init }); + if (url.startsWith('https://first')) return new Response(JSON.stringify({ message: 'temporarily unavailable' }), { status: 503 }); + return new Response(JSON.stringify({ url: 'https://cdn.example/clip.mov' }), { status: 201, headers: { 'content-type': 'application/json' } }); +}; + +const url = await uploadPublicMedia(file, sign, { servers: ['https://first', 'https://second/'], fetcher, timeoutMs: 1000 }); +check('falls back to a second independent media host', url === 'https://cdn.example/clip.mov' && calls.length === 2); +check('uploads the original video type and bytes', calls[1].init.method === 'PUT' && calls[1].init.body === file + && calls[1].init.headers['content-type'] === 'video/quicktime'); +check('hashes the file and binds the hash into Blossom auth', calls[1].init.headers['x-sha-256'] === bytesToHex(sha256(bytes)) + && signed.tags.some((tag) => tag[0] === 'x' && tag[1] === calls[1].init.headers['x-sha-256'])); +check('sends signed Blossom authorization', calls[1].init.headers.authorization.startsWith('Nostr ')); + +// a host that never answers is skipped after the probe, not waited on +{ + const puts = []; + const t0 = Date.now(); + const u = await uploadPublicMedia(file, sign, { + servers: ['https://dead.example', 'https://alive.example'], timeoutMs: 60_000, + fetcher: async (url, init) => { + if (url.startsWith('https://dead')) return new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(new Error('timeout')))); + if (init.method === 'HEAD') return new Response('', { status: 404 }); + puts.push(url); + return new Response(JSON.stringify({ url: 'https://alive.example/x.mov' }), { status: 201 }); + }, + }); + check('an unreachable host is skipped within seconds', u === 'https://alive.example/x.mov' && Date.now() - t0 < 7000 && puts.length === 1); +} +check('nostr.build is the first host', (await import('../src/media-upload.js')).PUBLIC_MEDIA_SERVERS[0] === 'https://blossom.nostr.build'); + +const bareHashUrl = await uploadPublicMedia(file, sign, { + servers: ['https://media.example'], timeoutMs: 1000, + fetcher: async () => new Response(JSON.stringify({ url: '/' + 'a'.repeat(64) }), { status: 201 }), +}); +check('adds the video extension when a Blossom server returns a bare hash URL', bareHashUrl.endsWith('.mov')); + +try { + await uploadPublicMedia(file, sign, { + servers: ['https://one', 'https://two'], timeoutMs: 1000, + fetcher: async () => new Response('', { status: 413 }), + }); + check('reports provider size rejection', false); +} catch (error) { + check('reports provider size rejection', error instanceof MediaUploadError && error.code === 'too-big'); +} + +console.log(ok ? '\n✅ public media upload works' : '\n❌ public media upload failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/notif-note-cache-test.js b/tools/notif-note-cache-test.js new file mode 100644 index 0000000..c0ceeca --- /dev/null +++ b/tools/notif-note-cache-test.js @@ -0,0 +1,128 @@ +// Referenced notification notes are fetched before the page opens, persisted, +// and available on the first paint after a reload. +// Run: bun tools/notif-note-cache-test.js +import puppeteer from 'puppeteer-core'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +import { buildHtml } from '../build.js'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let ok = true; +const check = (name, pass, detail = '') => { + console.log(` ${pass ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`); + if (!pass) ok = false; +}; +const now = Math.floor(Date.now() / 1000); +const actorSk = generateSecretKey(); +const actorPk = getPublicKey(actorSk); +const noteSk = generateSecretKey(); +const noteText = 'This notification excerpt came from the local note cache.'; +const target = finalizeEvent({ kind: 1, created_at: now - 120, tags: [], content: noteText }, noteSk); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ + port: 5307, + fetch(req) { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); + }, +}); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument((targetEvent) => { + window.__targetReqs = 0; + const Real = window.WebSocket; + window.WebSocket = function (...args) { + const ws = new Real(...args); + const send = ws.send.bind(ws); + ws.send = (data) => { + try { + const msg = JSON.parse(data); + if (msg[0] === 'REQ') { + const filter = msg[2] || {}; + const hit = (filter.ids || []).includes(targetEvent.id); + if (hit) window.__targetReqs++; + setTimeout(() => { + if (hit) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', msg[1], targetEvent]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', msg[1]]) })); + }, 0); + return; + } + if (msg[0] === 'EVENT') return; + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; + Object.assign(window.WebSocket, Real); +}, target); + +const clickText = (text) => page.evaluate((needle) => { + const el = [...document.querySelectorAll('button')].find((node) => node.textContent.trim().toLowerCase().includes(needle)); + if (!el) return false; + el.click(); return true; +}, text); +const waitText = async (text, timeout = 20_000) => { + for (let elapsed = 0; elapsed < timeout; elapsed += 200) { + if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(text.toLowerCase())) return true; + await sleep(200); + } + return false; +}; +const openNotifications = () => page.evaluate(() => { + const el = [...document.querySelectorAll('button')].find((node) => /notifications/i.test(node.getAttribute('aria-label') || '')); + if (!el) return false; + el.click(); return true; +}); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5307/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await clickText('create a new wallet'); + await page.waitForSelector('.words .w .t'); + const mnemonic = (await page.$$eval('.words .w .t', (nodes) => nodes.map((node) => node.textContent.trim()))).join(' '); + await clickText('skip verification'); + await waitText('receive'); + const base = cacheKeyFor(mnemonic + '\n'); + const me = getPublicKey(privateKeyFromSeedWords(mnemonic)); + const item = { id: 'a'.repeat(64), what: 'react', actor: actorPk, target: target.id, hint: [], emoji: '❤️', ts: now - 30 }; + await page.evaluate(([key, owner, notification]) => { + const state = JSON.parse(localStorage.getItem(key + ':messages') || '{}'); + state.notifs = [notification]; state.notifsPk = owner; + localStorage.setItem(key + ':messages', JSON.stringify(state)); + }, [base, me, item]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive'); + + let cached = false; + for (let i = 0; i < 75; i++) { + cached = await page.evaluate(([key, id]) => { + const state = JSON.parse(localStorage.getItem(key + ':messages') || '{}'); + return state.notifNoteCache && state.notifNoteCache[id]?.content; + }, [base, target.id]); + if (cached) break; + await sleep(200); + } + check('the referenced note is warmed and persisted', cached === noteText); + check('the relay was queried while the page was still closed', (await page.evaluate(() => window.__targetReqs)) > 0); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive'); + check('notifications opens from the bottom navigation', await openNotifications()); + await sleep(80); + const firstPaint = await page.evaluate(() => document.body.innerText); + check('the cached excerpt is present immediately', firstPaint.includes('This notification excerpt')); + check('there is no Fetching note placeholder', !firstPaint.includes('Fetching note')); + check('the persisted note avoids another target query', (await page.evaluate(() => window.__targetReqs)) === 0); +} finally { + await browser.close(); + server.stop(true); +} + +console.log(ok ? '\n✅ notification note cache works' : '\n❌ notification note cache failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/poll-test.js b/tools/poll-test.js new file mode 100644 index 0000000..b88a4db --- /dev/null +++ b/tools/poll-test.js @@ -0,0 +1,93 @@ +// Polls (NIP-88, kind 1068): shown as their options with each one's share of +// the vote (kind 1018), votable from the feed and the thread, and pictured +// in a quote card. Uses a real poll from the relays; outgoing votes are +// caught at the socket so the test never votes on someone's real poll. +// +// Run: bun tools/poll-test.js +import puppeteer from 'puppeteer-core'; +import { SimplePool } from 'nostr-tools/pool'; +import { cacheKeyFor } from '../src/wallet.js'; +import { neventOf } from '../src/nostr.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const POLL_ID = '000002f7c6a38223a9843d6e93ce8c15a3048bc95003690d74341cf9de372bfc'; +const pool = new SimplePool(); +const [poll] = await pool.querySync(['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub', 'wss://relay.nostrplebs.com'], { ids: [POLL_ID] }, { maxWait: 6000 }); +if (!poll) { console.log(' ✗ the poll is not on the relays'); process.exit(1); } +const labels = poll.tags.filter((x) => x[0] === 'option').map((x) => x[2]); + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5273, fetch: () => 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.__votes = []; + const send = WebSocket.prototype.send; + WebSocket.prototype.send = function (d) { + if (typeof d === 'string' && d.startsWith('["EVENT"') && /"kind":1018\b/.test(d)) { window.__votes.push(JSON.parse(d)[1]); return; } + return send.call(this, d); + }; +}); +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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5273/', { 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 Q = 'a'.repeat(63) + '9'; + await page.evaluate(([k, poll, q, ref]) => { + const now = Math.floor(Date.now() / 1000); + const quoting = { id: 'd'.repeat(64), pubkey: q, kind: 1, created_at: now, content: 'vote on this nostr:' + ref, tags: [] }; + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', poll.pubkey], ['p', q]], c: '', at: now })); + localStorage.setItem(k + ':feedNotes', JSON.stringify([quoting, poll])); + localStorage.setItem(k + ':profiles', JSON.stringify({ [poll.pubkey]: { name: 'Poll Author', t: Date.now() }, [q]: { name: 'Quoter', t: Date.now() } })); + }, [base, poll, Q, neventOf(poll.id, poll.pubkey)]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await waitFor(() => document.querySelectorAll('.notes-feed .poll').length >= 2, 20000); + + const feedPoll = await page.evaluate(() => { + const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((r) => r.querySelector(':scope .poll button.poll-opt')); + return row && { opts: [...row.querySelectorAll('.poll-opt .poll-label')].map((n) => n.textContent), text: row.innerText }; + }); + check('a poll in the feed shows its options', !!feedPoll && JSON.stringify(feedPoll.opts) === JSON.stringify(labels), JSON.stringify(feedPoll?.opts)); + check('...and not the "doesn’t show" placeholder', !(await page.evaluate(() => document.body.innerText.includes('doesn’t show')))); + check('its votes come in from the relays', await waitFor(() => /\d+ votes?/.test(document.querySelector('.notes-feed .poll-foot')?.textContent || ''), 15000), + await page.evaluate(() => document.querySelector('.notes-feed .poll-foot')?.textContent)); + const quoted = await page.evaluate(() => { const q = document.querySelector('.quote-card .poll'); return q && { n: q.querySelectorAll('.poll-opt.static').length, buttons: q.querySelectorAll('button').length }; }); + check('a quoted poll is pictured in its card, not votable there', !!quoted && quoted.n === labels.length && quoted.buttons === 0, JSON.stringify(quoted)); + + // vote for the last option from the feed + const before = await page.evaluate(() => location.href); + await page.evaluate(() => { const b = [...document.querySelectorAll('.notes-feed button.poll-opt')].pop(); b.click(); }); + await waitFor(() => window.__votes.length > 0, 10000); + const vote = await page.evaluate(() => window.__votes[0]); + const lastId = poll.tags.filter((x) => x[0] === 'option').pop()[1]; + check('tapping an option publishes a kind 1018 vote for it', vote && vote.kind === 1018 + && vote.tags.some((x) => x[0] === 'e' && x[1] === poll.id) && vote.tags.filter((x) => x[0] === 'response').map((x) => x[1]).join() === lastId, + JSON.stringify(vote?.tags)); + check('...marked as mine at once', await waitFor(() => !!document.querySelector('.notes-feed button.poll-opt.mine'), 3000)); + check('...without opening the thread', (await page.evaluate(() => location.href)) === before); + + // the thread: the poll as its root + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.querySelector(':scope .poll button.poll-opt')); r.querySelector('.note-text').click(); }); + check('the poll opens as a thread', await waitFor(() => !!document.querySelector('.thread-page .poll button.poll-opt'), 10000)); + check('...still showing my vote', await page.evaluate(() => !!document.querySelector('.thread-page .poll-opt.mine'))); + if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(500); await page.screenshot({ path: process.env.SHOT }); } + check('...with its replies', await waitFor(() => document.querySelectorAll('.thread-page [data-zap-post]').length > 1, 15000)); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ polls read and vote' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/post-actions-test.js b/tools/post-actions-test.js index d8c4c84..17f8372 100644 --- a/tools/post-actions-test.js +++ b/tools/post-actions-test.js @@ -81,7 +81,7 @@ try { // quoting carries the post into the composer as a nostr: reference await page.evaluate(() => { const b = [...document.querySelectorAll('.note-act')].find((x) => x.getAttribute('aria-label') === 'Quote'); b.click(); }); await sleep(900); - const draft = await page.evaluate(() => (document.querySelector('.chat-page textarea') || {}).value || ''); + const draft = await page.evaluate(() => (document.querySelector('.chat-page coinos-text') || {}).value || ''); check('quoting opens the composer with the reference in it', /nostr:nevent1/.test(draft), draft.slice(0, 40) || 'empty'); // A reply can carry a picture, the same as a post can. diff --git a/tools/post-cancel-test.js b/tools/post-cancel-test.js new file mode 100644 index 0000000..217e743 --- /dev/null +++ b/tools/post-cancel-test.js @@ -0,0 +1,20 @@ +// Canceling a new post closes the composer and discards every part of its +// saved state, including a late attachment result. +import assert from 'node:assert/strict'; + +const source = await Bun.file('src/features/messages.js').text(); +const start = source.indexOf('function cancelPost()'); +const end = source.indexOf('\n }', start); +const cancel = source.slice(start, end); +assert(start > 0, 'post cancel handler exists'); +assert(cancel.includes('postAttachGeneration++'), 'late uploads are invalidated'); +assert(cancel.includes('ui.profCompose = null'), 'composer is closed'); +assert(cancel.includes('ui.postMedia = []'), 'pending attachment metadata is removed'); +assert(cancel.includes('ui.postPreview = false'), 'preview state is removed'); +assert(cancel.includes('discardDraft(POST_DRAFT)'), 'persisted draft is removed immediately'); +assert(source.includes("composeText().length > 20"), 'drafts over 20 characters require confirmation'); +assert(source.includes("ui.postUploading || (ui.postMedia || []).length"), 'attachments require confirmation'); +assert(source.includes("onClick: requestCancelPost }, t('cancel')"), 'Cancel button uses the guarded discard handler'); +assert(source.includes("onClick: cancelPost }, t('postDiscard')"), 'warning can confirm the discard'); + +console.log('✓ Cancel warns for substantial drafts, then closes the form and removes its complete saved draft'); diff --git a/tools/quote-card-test.js b/tools/quote-card-test.js index 27a1b22..d2d7d7e 100644 --- a/tools/quote-card-test.js +++ b/tools/quote-card-test.js @@ -25,11 +25,24 @@ const found = await pool.querySync(['wss://relay.primal.net', 'wss://nos.lol'], const target = (found || []).filter((e) => e.content && e.content.length > 20)[0]; if (!target) { console.log(' ✗ no note to quote (relays quiet)'); process.exit(1); } const nevent = neventOf(target.id, target.pubkey); +const FAR = 'nevent1qvzqqqqqqypzqntcggz30qhq60ltqdx32zku9d46unhrkjtcv7fml7jx3dh4h94nqqsqchs0pz4hnqm6jurakjwcd6wcrph7wyy80j0ljup68p4hynvsz3q3nhre2'; const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5269, fetch: () => 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(); +// --slow: the relays miss the quoted note the first time it is asked for +// (ours was down for an evening once), so the row freezes with the fallback +// link; the next ask, half a minute on, finds it. +const SLOW_MS = 32000; +await page.evaluateOnNewDocument((id) => { + const send = WebSocket.prototype.send; + const t0 = Date.now(); + WebSocket.prototype.send = function (d) { + if (localStorage.getItem('__slowQuote') && typeof d === 'string' && d.includes(id) && d.startsWith('["REQ"') && Date.now() - t0 < 20000) return; + return send.call(this, d); + }; +}, target.id); 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; }; try { @@ -47,18 +60,26 @@ try { await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const A = 'a'.repeat(63) + '9'; - await page.evaluate(([k, pk, ref]) => { + await page.evaluate(([k, pk, ref, FAR]) => { localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now()/1000) })); localStorage.setItem(k + ':feedNotes', JSON.stringify([ { id: 'd'.repeat(64), pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'what he said nostr:' + ref, tags: [] }, + // quotes a note that sits only on nostr.mom — not in the reference, not in its author's relay list + { id: 'e'.repeat(64), pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000) - 60, content: 'I remember this.\n\nnostr:' + FAR, tags: [] }, ])); - }, [base, A, nevent]); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Quoter', t: Date.now() } })); + }, [base, A, nevent, FAR]); + if (process.argv.includes('--slow')) await page.evaluate(() => localStorage.setItem('__slowQuote', '1')); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); - await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); - await sleep(1000); - await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); await sleep(7000); + if (process.argv.includes('--slow')) { + for (let i = 0; i < 40 && !(await page.evaluate(() => !!document.querySelector('.quote-card'))); i++) await sleep(250); + check('a late quote first paints as a placeholder card, not a link', await page.evaluate(() => !document.body.innerText.includes('view note') + && [...document.querySelectorAll('.quote-card')].some((q) => /Couldn.t find|Fetching note/.test(q.innerText)))); + await sleep(SLOW_MS + 4000); + } const card = await page.evaluate(() => { const q = document.querySelector('.quote-card'); @@ -70,7 +91,12 @@ try { check('...with whose note it is', !!(card && card.hasAvatar), card ? card.text.slice(0, 50) : ''); const body = target.content.replace(/\s+/g, ' ').slice(0, 24); check('...and what it actually said', !!(card && card.text.replace(/\s+/g, ' ').includes(body.slice(0, 18))), body); - check('no "view note" link left in the post', !(await page.evaluate(() => document.body.innerText)).includes('view note')); + if (!process.argv.includes('--slow')) { + const far = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.innerText.includes('Quoter') && n.innerText.includes('I remember this')); return r && r.querySelector('.quote-card')?.innerText; }); + check('a quote found only on a far relay still shows as its card', !!far && !/Couldn.t find|Fetching/.test(far), JSON.stringify((far || '').slice(0, 60))); + } + // (a quote inside a quote stays a link on purpose: one level of cards) + check('no "view note" link left in the post', !(await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].some((a) => a.textContent === 'view note' && !a.closest('.quote-card'))))); // the outer post keeps its actions; the quote has none of its own const acts = await page.evaluate(() => ({ outer: document.querySelectorAll('.notes-feed > .row .note-acts').length, diff --git a/tools/room-faces-test.js b/tools/room-faces-test.js new file mode 100644 index 0000000..ab673ff --- /dev/null +++ b/tools/room-faces-test.js @@ -0,0 +1,88 @@ +// Opening a community must not paint everyone as a punk and then swap in +// their real pictures: faces are warmed on hover/press (and while the list +// sits there) and the tap waits a moment for them. A fresh wallet has no +// profiles cached — the worst case — against the real coinos community. +// +// Run: bun tools/room-faces-test.js [--no-hover] +import puppeteer from 'puppeteer-core'; +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: 5277, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const 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; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5277/', { 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.querySelectorAll('.app-bottom-nav .app-nav-button')][1].click()); + await waitText('members', 20000); + // let the community's history arrive (the list shows before it has) + await sleep(process.argv.includes('--no-hover') ? 1500 : 6000); + const face = () => [...document.querySelectorAll('.chat-log .chat-row > .chat-avatar:not(.spacer), .chat-log .chat-row > .hat-wrap > .chat-avatar')] + .map((a) => a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'); + // name -> face state, per author (rows stream in, so positions shift) + const keyed = () => { const o = {}; for (const r of document.querySelectorAll('.chat-log .chat-row')) { + const a = r.querySelector(':scope > .chat-avatar:not(.spacer), :scope > .hat-wrap > .chat-avatar'); const n = r.querySelector('.chat-meta')?.firstElementChild?.textContent; + if (a && n) o[n] = a.classList.contains('ava-img') ? 'pic' : a.querySelector('img.punk') ? 'punk' : a.classList.contains('loading') ? 'wait' : 'other'; } return o; }; + await page.evaluate((fs) => { + const face = new Function('return ' + fs)(); + const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstAt = performance.now(); } }); + mo.observe(document.body, { childList: true, subtree: true }); + }, face.toString()); + const row = await page.evaluateHandle(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => /members/.test(r.innerText))); + const t0 = await page.evaluate(() => performance.now()); + if (!process.argv.includes('--no-hover')) await row.hover(); + await row.click(); + for (let i = 0; i < 60 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); + await sleep(1200); + const at1s = await page.evaluate(new Function('return (' + face.toString() + ')()')); + check('no face is still a blank circle a second after the room shows', !at1s.includes('wait'), JSON.stringify(tally(at1s))); + await sleep(4800); + const [first, at] = await page.evaluate(() => [window.__first, window.__firstAt]); + const last = await page.evaluate(new Function('return (' + face.toString() + ')()')); + const flips = (first || []).filter((f, i) => f === 'punk' && last[i] === 'pic').length; + console.log(' first paint after', Math.round(at - t0), 'ms:', JSON.stringify(tally(first)), ' settled:', JSON.stringify(tally(last))); + check('the room opens with its messages', (first || []).length > 0); + check('no face paints as a punk and then turns into a picture', flips === 0, flips + ' flipped'); + check('the tap waits no more than a moment', at - t0 < 2500, Math.round(at - t0) + 'ms'); + + if (process.argv.includes('--refresh')) { + // a reload inside the room: every face as it will stay, from the first + // frame — a known-faceless member is their punk at once, not a blank + // circle that turns into one. (Wait out the second ask that confirms + // who has no profile.) + await sleep(8000); + await page.evaluateOnNewDocument((fs, ks) => { + const face = new Function('return ' + fs)(); + const keyed = new Function('return ' + ks)(); + const mo = new MutationObserver(() => { if (!window.__first && document.querySelector('.chat-log .chat-row')) { window.__first = face(); window.__firstBy = keyed(); } }); + addEventListener('DOMContentLoaded', () => mo.observe(document.body, { childList: true, subtree: true })); + }, face.toString(), keyed.toString()); + await page.reload({ waitUntil: 'domcontentloaded' }); + for (let i = 0; i < 100 && !(await page.evaluate(() => !!window.__first)); i++) await sleep(100); + await sleep(6000); + const f2 = await page.evaluate(() => window.__first); + const l2 = await page.evaluate(new Function('return (' + face.toString() + ')()')); + const byName = await page.evaluate(new Function('return (' + keyed.toString() + ')()')); + const firstBy = await page.evaluate(() => window.__firstBy); + const changedWho = Object.keys(firstBy || {}).filter((k) => byName[k] && firstBy[k] !== byName[k] && !(firstBy[k] === 'punk' && byName[k] === 'other')); + if (changedWho.length) console.log(' changed:', changedWho.map((k) => k + ' ' + firstBy[k] + '→' + byName[k]).join(', ')); + const changed = changedWho.length; + console.log(' after reload, first paint:', JSON.stringify(tally(f2)), ' settled:', JSON.stringify(tally(l2))); + check('after a reload in the room, no face changes once painted', !!f2 && changed === 0, changed + ' changed'); + } +} finally { await browser.close(); server.stop(true); } +function tally(a) { const o = {}; for (const x of a || []) o[x] = (o[x] || 0) + 1; return o; } +console.log(ok ? '\n✅ a room opens with its faces' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js index 485e874..6bac353 100644 --- a/tools/thread-cache-test.js +++ b/tools/thread-cache-test.js @@ -19,9 +19,17 @@ check('a fresh store restores full content and tags by any viewed event', () => const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] }; store.save({ root, replies: [parent, long] }, long.id); const restored = createThreadStore(storage).find(long.id); - assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent] }); + assert.deepEqual(restored, { rootId: root.id, root, profiles: {}, counts: {}, replies: [long, parent], quotes: [] }); assert.equal(store.find(parent.id).root.id, root.id); }); +check('a thread rooted in a poll (kind 1068) is kept too', () => { + const poll = { ...note(40), kind: 1068, tags: [['option', '0', 'Yes!'], ['option', '1', 'No!']] }; + const answer = note(41, [['e', poll.id, '', 'root']]); + store.save({ root: poll, replies: [answer] }, poll.id); + const restored = createThreadStore(storage).find(poll.id); + assert.equal(restored?.root.kind, 1068); + assert.deepEqual(restored.replies, [answer]); +}); check('thread authors keep their names and local avatar thumbnails across reloads', () => { const thumb = 'data:image/webp;base64,AAAA'; const face = { name: 'Nostrich', picture: 'https://example.com/face.webp', eventAt: 50, @@ -36,6 +44,12 @@ check('thread action totals are present before relay refresh and survive a conte store.save({ root, replies: [parent, reply] }, reply.id); assert.deepEqual(createThreadStore(storage).find(reply.id).counts[reply.id], { likes: 29, boosts: 7, sats: 210 }); }); +check('posts quoted inside the thread are restored with it, and a later save keeps them', () => { + const q = note(77), poll = { ...note(78), kind: 1068 }; // a quote can be any kind + store.save({ root, replies: [parent, reply] }, reply.id, {}, {}, [q, poll]); + store.save({ root, replies: [parent, reply] }, reply.id); // a relay refresh with no quotes resolved yet + assert.deepEqual(createThreadStore(storage).find(reply.id).quotes, [q, poll]); +}); check('a large thread retains the selected reply and its ancestors', () => { const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']])); store.save({ root, replies: [...siblings, parent, reply] }, reply.id); @@ -70,7 +84,8 @@ try { const { messagesFeature } = await import(path); let requests = [], waiting = []; globalThis.__threadQuery = (filter) => { - if (filter.kinds?.includes(1) && (filter.ids || filter['#e'])) { + // root lookups by id carry no kind (a root can be a poll); reply queries are kind 1 + if (filter.ids || (filter.kinds?.includes(1) && filter['#e'])) { requests.push(filter); return new Promise((resolve) => waiting.push({ filter, resolve })); } diff --git a/tools/topic-depth-test.js b/tools/topic-depth-test.js new file mode 100644 index 0000000..9876224 --- /dev/null +++ b/tools/topic-depth-test.js @@ -0,0 +1,36 @@ +// A topic feed (#gardenstr) goes back as far as the relays remember: the +// big relays prune, so topics also ask archive-keeping ones, and a page +// whose posts were all filtered out (replies, muted, spam) is not the end. +// Before: ~5 posts, then "You're all caught up". +// +// Run: bun tools/topic-depth-test.js +import puppeteer from 'puppeteer-core'; +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: 5285, fetch: () => 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(); +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5285/feed/t/gardenstr', { waitUntil: 'domcontentloaded' }); + const count = () => page.evaluate(() => new Set([...document.querySelectorAll('.notes-feed [data-key]')].map((n) => n.getAttribute('data-key'))).size); + for (let i = 0; i < 60 && (await count()) < 3; i++) await sleep(500); + const first = await count(); + check('the topic opens with posts', first >= 3, first + ' posts'); + let seen = new Set(), ended = false; + const t0 = Date.now(); + while (Date.now() - t0 < 90_000 && seen.size < 60) { + for (const k of await page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-key]')].map((n) => n.getAttribute('data-key')))) seen.add(k); + ended = await page.evaluate(() => document.body.innerText.includes('all caught up')); + if (ended) break; + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await sleep(1500); + } + check('scrolling keeps finding older posts', seen.size >= 40, seen.size + ' posts' + (ended ? ', then "caught up"' : '')); + check('...without claiming to be caught up after a handful', !ended || seen.size >= 40); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a topic goes back' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/upload-progress-test.js b/tools/upload-progress-test.js new file mode 100644 index 0000000..25bf91c --- /dev/null +++ b/tools/upload-progress-test.js @@ -0,0 +1,37 @@ +// uploadPublicMedia reports how much of the file has gone out (XMLHttpRequest +// upload events — fetch() can't), from 0 to 1, on a slow uplink. +// +// Run: bun tools/upload-progress-test.js +import puppeteer from 'puppeteer-core'; +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const js = await Bun.build({ entrypoints: ['src/media-upload.js'], target: 'browser' }).then((b) => b.outputs[0].text()); +let got = 0; +const server = Bun.serve({ port: 5289, async fetch(req) { + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD', 'access-control-allow-headers': '*', 'access-control-expose-headers': 'x-reason' }; + const p = new URL(req.url).pathname; + if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors }); + if (p === '/upload' && req.method === 'PUT') { got = (await req.arrayBuffer()).byteLength; return Response.json({ url: 'http://localhost:5289/' + 'a'.repeat(64) }, { status: 201, headers: cors }); } + if (p === '/m.js') return new Response(js, { headers: { 'content-type': 'text/javascript' } }); + return new Response('<script type=module>import * as m from "/m.js"; window.m = m;</script>', { 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(); +try { + await page.goto('http://localhost:5289/'); + await page.waitForFunction(() => !!window.m); + const cdp = await page.createCDPSession(); + await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 20, downloadThroughput: 5e6, uploadThroughput: 400e3 }); + const r = await page.evaluate(async () => { + const seen = []; + const file = new File([new Uint8Array(1_200_000)], 'v.mp4', { type: 'video/mp4' }); + const sign = async (e) => ({ ...e, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) }); + const url = await window.m.uploadPublicMedia(file, sign, { servers: ['http://localhost:5289'], onProgress: (p) => seen.push(Math.round(p * 100)) }); + return { url, seen }; + }); + check('the upload lands', r.url.startsWith('http://localhost:5289/') && got === 1_200_000, got + ' bytes'); + check('progress climbs from 0 to 100 as the bytes go', r.seen[0] === 0 && r.seen.at(-1) === 100 && r.seen.filter((p) => p > 0 && p < 100).length >= 3 + && r.seen.every((p, i) => !i || p >= r.seen[i - 1]), JSON.stringify(r.seen.slice(0, 15)) + (r.seen.length > 15 ? '…' : '')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ uploads report their progress' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js new file mode 100644 index 0000000..ce1a936 --- /dev/null +++ b/tools/video-compress-test.js @@ -0,0 +1,126 @@ +// A phone video is shrunk on the device before upload: 1080p at 20 Mbps in, +// H.264 720p around 2.5 Mbps out, moov first (streams while downloading), +// audio kept. Pasted into the reply box like a keyboard/gallery pick; the +// upload is caught and its bytes checked with ffprobe. +// +// Run: bun tools/video-compress-test.js +import puppeteer from 'puppeteer-core'; +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 SRC = '/tmp/coinos-video-compress-src.mp4', OUT = '/tmp/coinos-video-compress-out.mp4'; +// --portrait: the way phones record upright video — landscape frames plus a +// 90° rotation flag in the container +const PORTRAIT = process.argv.includes('--portrait'); +Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '5', '-c:v', 'libx264', '-b:v', '20M', '-maxrate', '20M', '-bufsize', '20M', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', SRC]); +if (PORTRAIT) { + // (ffmpeg 4 writes the flag only on a remux) + Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-i', SRC, '-c', 'copy', '-metadata:s:v:0', 'rotate=90', SRC + '.rot.mp4']); + Bun.spawnSync(['mv', SRC + '.rot.mp4', SRC]); + const r = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-of', 'json', SRC]).stdout)).streams[0]; + console.log(' source rotation:', JSON.stringify(r.side_data_list || r.tags?.rotate || 'none')); +} +const srcSize = Bun.file(SRC).size; +// the split build: the compressor and Mediabunny are chunks loaded on demand +const built = Bun.spawnSync(['bun', 'run', 'build.js'], { stdout: 'pipe', stderr: 'pipe' }); +if (built.exitCode) { console.log(new TextDecoder().decode(built.stderr)); process.exit(1); } +const dist = 'dist'; +const server = Bun.serve({ port: 5287, fetch: async (req) => { + const p = new URL(req.url).pathname; + if (p === '/src.mp4') return new Response(Bun.file(SRC), { headers: { 'content-type': 'video/mp4' } }); + const f = Bun.file(dist + (p === '/' || !p.includes('.') ? '/index.html' : p)); + return (await f.exists()) ? new Response(f) : new Response(Bun.file(dist + '/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +// the upload goes out by XMLHttpRequest (for its progress events): keep a +// copy of the body, and answer at the network layer — nothing leaves +await page.evaluateOnNewDocument(() => { + const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function (m, url) { this.__m = m; this.__url = String(url); return open.apply(this, arguments); }; + XMLHttpRequest.prototype.send = function (body) { + if (this.__m === 'PUT' && /nostr\.build|nostr\.download/.test(this.__url) && body instanceof Blob) { + body.arrayBuffer().then((buf) => { window.__put = { size: body.size, type: body.type, buf }; }); + } + return send.apply(this, arguments); + }; + // every state the progress bar shows + window.__prog = []; + new MutationObserver(() => { + const m = document.querySelector('.media-progress'); + const s = m ? m.innerText.replace(/\s+/g, ' ').trim() : null; + if (s && window.__prog.at(-1) !== s) window.__prog.push(s); + }).observe(document, { childList: true, subtree: true, characterData: true, attributes: true }); +}); +await page.setRequestInterception(true); +page.on('request', (req) => { + const u = req.url(); + if (!/nostr\.build|nostr\.download/.test(u)) return req.continue(); + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'PUT, HEAD, GET', 'access-control-allow-headers': '*' }; + if (req.method() === 'OPTIONS') return req.respond({ status: 204, headers: cors }); + if (req.method() === 'HEAD') return req.respond({ status: 200, headers: cors }); + return req.respond({ status: 201, headers: cors, contentType: 'application/json', + body: JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }) }); +}); +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) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5287/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(600); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + await click('skip verification'); + await waitText('receive', 20000); + check('the browser can encode H.264 (WebCodecs)', await page.evaluate(async () => (await VideoEncoder.isConfigSupported({ codec: 'avc1.42001f', width: 1280, height: 720, bitrate: 2.5e6 })).supported)); + // the post composer: the pencil on the feed + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await sleep(1500); + await page.evaluate(() => document.querySelector('button[aria-label="New post"], button[title="New post"]')?.click()); + const box = await waitFor(() => !!document.querySelector('.post-input'), 8000); + check('the post composer opens', box); + const t0 = Date.now(); + await page.evaluate(async () => { + const b = await (await fetch('/src.mp4')).blob(); + const dt = new DataTransfer(); + dt.items.add(new File([b], 'phone.mp4', { type: 'video/mp4' })); + document.querySelector('.post-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + }); + const done = await waitFor(() => !!window.__put, 120000); + await waitFor(() => !document.querySelector('.media-progress'), 10000); + const prog = await page.evaluate(() => window.__prog); + const pcts = (stage) => prog.filter((x) => x.startsWith(stage)).map((x) => +x.match(/(\d+)%/)[1]); + const shrink = pcts('Shrinking video'), up = pcts('Uploading'); + check('a bar shows the shrinking with its percentage', shrink.length >= 3 && shrink.some((p) => p > 0 && p < 100), JSON.stringify(shrink.slice(0, 12))); + // (the answer here is instant, so only its start shows — the percentages + // themselves are upload-progress-test's) + check('...then the upload with its own', up.length >= 1 && prog.findIndex((x) => x.startsWith('Uploading')) > prog.findIndex((x) => x.startsWith('Shrinking')), JSON.stringify(up)); + check('...and goes away when it is done', await page.evaluate(() => !document.querySelector('.media-progress'))); + const put = done && await page.evaluate(() => ({ size: window.__put.size, type: window.__put.type })); + const secs = ((Date.now() - t0) / 1000).toFixed(1); + check('the upload is the shrunk video', !!put && put.type === 'video/mp4' && put.size < srcSize * 0.35, + put ? `${(srcSize / 1e6).toFixed(1)} MB → ${(put.size / 1e6).toFixed(1)} MB in ${secs}s` : 'no upload'); + if (put) { + const b64 = await page.evaluate(() => { let s = ''; const u = new Uint8Array(window.__put.buf); for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000)); return btoa(s); }); + await Bun.write(OUT, Buffer.from(b64, 'base64')); + const probe = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', OUT]).stdout)); + const v = probe.streams.find((s) => s.codec_type === 'video'), a = probe.streams.find((s) => s.codec_type === 'audio'); + check('...H.264 at 720p', v?.codec_name === 'h264' && Math.min(v.width, v.height) === 720, `${v?.codec_name} ${v?.width}x${v?.height}`); + if (PORTRAIT) { + // upright on screen: either the frames are portrait, or they keep the rotation flag + const rot = Math.abs(+((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0)); + check('...still upright (portrait)', (v.height > v.width) !== (rot === 90 || rot === 270), `${v.width}x${v.height} rotation ${rot}`); + } + check('...around 2.5 Mbps', v && +v.bit_rate < 3.5e6, (+v?.bit_rate / 1e6).toFixed(2) + ' Mbps'); + check('...the whole clip, with its sound', Math.abs(+probe.format.duration - 5) < 0.3 && !!a, probe.format.duration + 's, audio ' + a?.codec_name); + const head = new Uint8Array(await Bun.file(OUT).slice(0, 4096).arrayBuffer()); + const txt = new TextDecoder('latin1').decode(head); + check('...moov before mdat, so it plays while downloading', txt.indexOf('moov') >= 0 && (txt.indexOf('mdat') < 0 || txt.indexOf('moov') < txt.indexOf('mdat'))); + } +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ videos are shrunk before upload' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
