aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 06:49:19 -0700
committerAdam Soltys <[email protected]>2026-10-01 06:49:19 -0700
commit68ff290e07c0e1786cf8b8f3a87e38872ad7f8ba (patch)
treec85847c8f4a06b0a6b7dab8abb2d2eb54fefa226 /tools
parent4b3291fdbd0b34b4d2df91a59bea93885de29508 (diff)
parent5ff909d37fe32ba04a841941593d95c7b3498ec3 (diff)
downloadcoinosv3-68ff290e07c0e1786cf8b8f3a87e38872ad7f8ba.tar.gz
coinosv3-68ff290e07c0e1786cf8b8f3a87e38872ad7f8ba.zip
Merge prod into master
Diffstat (limited to 'tools')
-rw-r--r--tools/account-access-test.js2
-rw-r--r--tools/avatar-profile-test.js124
-rw-r--r--tools/comments-test.js99
-rw-r--r--tools/compose-preview-test.js6
-rw-r--r--tools/composer-media-test.js103
-rw-r--r--tools/feed-bottom-nav-test.js99
-rw-r--r--tools/feed-cache-test.js26
-rw-r--r--tools/feed-nav-destination-test.js18
-rw-r--r--tools/feed-new-posts-test.js74
-rw-r--r--tools/feed-switch-seed-test.js125
-rw-r--r--tools/feed-video-aspect-test.js117
-rw-r--r--tools/feed-wallet-back-test.js25
-rw-r--r--tools/image-tap-test.js70
-rw-r--r--tools/image-zoom-test.js84
-rw-r--r--tools/media-upload-test.js61
-rw-r--r--tools/notif-note-cache-test.js128
-rw-r--r--tools/poll-test.js93
-rw-r--r--tools/post-actions-test.js2
-rw-r--r--tools/post-cancel-test.js20
-rw-r--r--tools/quote-card-test.js38
-rw-r--r--tools/room-faces-test.js88
-rw-r--r--tools/thread-cache-test.js19
-rw-r--r--tools/topic-depth-test.js36
-rw-r--r--tools/upload-progress-test.js37
-rw-r--r--tools/video-compress-test.js126
25 files changed, 1602 insertions, 18 deletions
diff --git a/tools/account-access-test.js b/tools/account-access-test.js
index ad6ce3f..d2685de 100644
--- a/tools/account-access-test.js
+++ b/tools/account-access-test.js
@@ -36,6 +36,8 @@ try {
check('avatar opens Accounts with sign-in and logout', await page.evaluate(() => ['Accounts', 'Sign into another account', 'Log out'].every((s) => document.body.innerText.includes(s))));
const saved = await page.evaluate(() => localStorage.getItem('btc-wallet-watch'));
await click('Sign into another account');
+ check('sign-in opens on Create new, not the seed import box', await page.evaluate(() =>
+ document.querySelector('.tabs .active')?.textContent.includes('Create new') && !document.querySelector('.card textarea')));
// Expand the Nostr login card without supplying a private key.
await click('Sign in with Nostr');
await page.waitForSelector('input[placeholder="nsec, private key, or bunker://…"]');
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-cache-test.js b/tools/feed-cache-test.js
index 995e01a..3df9447 100644
--- a/tools/feed-cache-test.js
+++ b/tools/feed-cache-test.js
@@ -12,14 +12,28 @@ storage.setItem('wallet:funds', 'untouched');
assert.equal(cache.read('following').length, 30);
assert.equal(JSON.parse(storage.getItem('wallet:feedNotes')).notes.length, 30);
for (let i = 0; i < 5; i++) cache.save('custom' + i, notes);
-assert.equal(cache.read('following').length, 0);
-assert.equal([...values.keys()].filter((k) => k.includes('feedNotes')).length, 4);
-cache.read('custom1');
-cache.save('custom5', notes);
-assert.equal(cache.read('custom2').length, 0);
+const stored = (id) => JSON.parse(storage.getItem(id === 'following' ? 'wallet:feedNotes' : 'wallet:feedNotes:' + id) || 'null');
+assert.equal(stored('following').notes.length, 5, 'a feed past the four most recent keeps seed posts');
+assert.equal(stored('custom4').notes.length, 30);
+assert.equal(cache.read('following').length, 5, 'and reads them back');
+for (let i = 5; i < 14; i++) cache.save('custom' + i, notes);
+assert.equal([...values.keys()].filter((k) => k.includes('feedNotes')).length, 12);
+assert.equal(stored('custom0'), null, 'the least recently read goes first');
+assert.equal(stored('custom3').notes.length, 5);
+cache.read('custom3'); cache.save('custom3', notes);
+assert.equal(stored('custom3').notes.length, 30, 'reopened, it gets a full page again');
+assert.equal(stored('custom10').notes.length, 5, 'and the oldest of the four drops to seed posts');
+// seeds warmed ahead never outrank or overwrite a feed that was read
+cache.seed('custom13', notes.slice(0, 2));
+assert.equal(stored('custom13').notes.length, 30);
+assert.equal(cache.has('warm'), false);
+cache.seed('warm', notes);
+assert.equal(cache.has('warm'), true);
+assert.equal(stored('warm').notes.length, 5);
+assert.equal(stored('custom13').notes.length, 30);
cache.save('big', notes.map((e) => ({ ...e, content: 'x'.repeat(15000) })));
assert([...values].filter(([k]) => k.includes('feedNotes')).reduce((n, [, v]) => n + v.length, 0) <= 256000);
time += 8 * 86400_000;
assert.equal(cache.read('big').length, 0);
assert.equal(storage.getItem('wallet:funds'), 'untouched');
-console.log('✓ legacy caches shrink; 30 posts/feed, four feeds, byte budget, LRU and seven-day expiry enforced');
+console.log('✓ legacy caches shrink; 30 posts for the four most recent feeds, 5 seed posts for up to 12, byte budget, LRU and seven-day expiry enforced');
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-switch-seed-test.js b/tools/feed-switch-seed-test.js
new file mode 100644
index 0000000..a012dfe
--- /dev/null
+++ b/tools/feed-switch-seed-test.js
@@ -0,0 +1,125 @@
+// Switching to a feed paints posts at once, never a bare spinner, even when
+// the relays are slow: a feed that was never opened borrows matching posts
+// other open feeds already hold, and once the feed on screen settles the
+// other feeds' first posts are fetched ahead into the disk cache.
+// Run: bun tools/feed-switch-seed-test.js
+import puppeteer from 'puppeteer-core';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const A = generateSecretKey(), B = generateSecretKey();
+const PK_A = getPublicKey(A), PK_B = getPublicKey(B);
+const now = Math.floor(Date.now() / 1000);
+const post = (sk, i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, sk);
+// Alice is followed; one of her posts is on #bitcoin. Bob is only in his own feed.
+const alice = Array.from({ length: 12 }, (_, i) => post(A, i, 'alice post ' + (i + 1), i === 3 ? [['t', 'bitcoin']] : []));
+const bob = Array.from({ length: 12 }, (_, i) => post(B, i, 'bob post ' + (i + 1)));
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+// Stub relay: kind-1 requests answered from the posts above, by author and
+// (for topics) never — until window.__slow, when every answer takes 8 s.
+await page.evaluateOnNewDocument((all) => {
+ const Real = window.WebSocket;
+ window.__reqs = [];
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const f = m[2] || {};
+ const evs = (f.kinds || []).includes(1) && !f['#e'] && !f.ids && !f['#t'] && f.authors
+ ? all.filter((e) => f.authors.includes(e.pubkey) && (!f.until || e.created_at <= f.until)).slice(0, f.limit || 100) : [];
+ if (f.authors) window.__reqs.push({ authors: f.authors, limit: f.limit, slow: !!window.__slow });
+ setTimeout(() => {
+ for (const e of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, window.__slow ? 8000 : 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, [...alice, ...bob]);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const chip = (name) => page.evaluate((n) => { const e = [...document.querySelectorAll('.feed-chip')].find((b) => b.textContent.trim() === n); if (e) e.click(); return !!e; }, name);
+const rows = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row[data-key]')].map((r) => r.innerText.match(/(alice|bob) post \d+/)?.[0]).filter(Boolean));
+const spinner = () => page.evaluate(() => !document.querySelector('.notes-feed') && !!document.querySelector('.spinner'));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, a, b, ns]) => {
+ const t = Date.now();
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a]], c: '', at: Math.floor(t / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [a]: { name: 'Alice', t }, [b]: { name: 'Bob', t } }));
+ const s = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
+ s.feeds = [{ id: 'bob', name: 'Bobfeed', follows: false, authors: [b], topics: [], packs: [] },
+ { id: 'btc', name: 'Btcfeed', follows: false, authors: [], topics: ['bitcoin'], packs: [] }];
+ localStorage.setItem(k + ':messages', JSON.stringify(s));
+ localStorage.setItem('btc-wallet-feed', 'following');
+ }, [base, PK_A, PK_B, alice]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await page.waitForSelector('.notes-feed', { timeout: 15000 });
+ check('Following opens on its cached posts', (await rows())[0] === 'alice post 1', JSON.stringify((await rows()).slice(0, 2)));
+
+ // the feed on screen settles, then Bob's feed is fetched ahead
+ let seeded = null;
+ for (let i = 0; i < 40 && !seeded; i++) {
+ await sleep(250);
+ seeded = await page.evaluate((k) => JSON.parse(localStorage.getItem(k + ':feedNotes:bob') || 'null'), base);
+ }
+ check('another feed is fetched ahead into the disk cache', seeded?.seed && seeded.notes.length > 0 && seeded.notes.length <= 5,
+ JSON.stringify(seeded && { seed: seeded.seed, n: seeded.notes.length }));
+ check('...with a small request', (await page.evaluate(() => window.__reqs)).some((r) => r.authors.join() === PK_B && r.limit <= 10));
+
+ // from here every relay answer takes 8 s
+ await page.evaluate(() => { window.__slow = true; });
+ check('the Bob chip is there', await chip('Bobfeed'));
+ await sleep(400);
+ const bobRows = await rows();
+ check('switching to a feed fetched ahead paints its posts at once', bobRows[0] === 'bob post 1', JSON.stringify(bobRows.slice(0, 3)));
+ check('...not a spinner', !(await spinner()));
+
+ await chip('Btcfeed');
+ await sleep(400);
+ const btcRows = await rows();
+ check('a topic feed never opened borrows a matching post an open feed holds', btcRows.includes('alice post 4') && btcRows.length === 1, JSON.stringify(btcRows));
+ check('...not a spinner', !(await spinner()));
+ check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
+} catch (e) {
+ console.log('✗ threw', e.message); ok = false;
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ feed switching paints at once' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-video-aspect-test.js b/tools/feed-video-aspect-test.js
new file mode 100644
index 0000000..214b977
--- /dev/null
+++ b/tools/feed-video-aspect-test.js
@@ -0,0 +1,117 @@
+// A video in the feed paints at its own shape, not a 16:9 frame with black
+// bands either side: from the post's imeta `dim` when it says, else from the
+// clip's metadata fetched before the row is admitted. A clip with no known
+// shape still gets the 16:9 box.
+// Run: bun tools/feed-video-aspect-test.js (CHROME=<path> to pick a browser)
+import puppeteer from 'puppeteer-core';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { mkdtempSync } from 'fs';
+import { tmpdir } from 'os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/vid-');
+const clip = (name, size) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', `testsrc=size=${size}:rate=10`, '-t', '1',
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${name}`]);
+ if (p.exitCode !== 0) throw new Error('ffmpeg failed');
+};
+clip('tall.mp4', '360x480'); clip('wide.mp4', '640x360'); clip('told.mp4', '360x640');
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ // this one's metadata arrives too late for a probe: only the imeta dim can shape it
+ if (path === '/v/told.mp4') await sleep(6000);
+ if (path.startsWith('/v/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'video/mp4' } });
+ if (path === '/broken.mp4') return new Response('nope', { status: 404 });
+ 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 base = server.url.origin;
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK);
+const notes = [
+ post(0, 'tall clip ' + base + '/v/tall.mp4'),
+ post(1, 'wide clip ' + base + '/v/wide.mp4'),
+ // imeta says 9:16; the file is too, but the probe must not be needed
+ post(2, 'told clip ' + base + '/v/told.mp4', [['imeta', 'url ' + base + '/v/told.mp4', 'm video/mp4', 'dim 1080x1920']]),
+ post(3, 'broken clip ' + base + '/broken.mp4'),
+];
+
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+const videoReqs = [];
+page.on('request', (r) => { if (/\/v\/told\.mp4/.test(r.url())) videoReqs.push(Date.now()); });
+await page.evaluateOnNewDocument(() => {
+ // no autoplay: a clip that played keeps its first box, and this test is
+ // about the box the settled feed row paints
+ HTMLMediaElement.prototype.play = function () { return Promise.reject(new Error('no autoplay in this test')); };
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; }
+ if (m[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+// each clip's box, as painted in the feed (before any metadata of its own)
+const boxes = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.notes-feed video.note-video')].map((v) => {
+ const r = v.getBoundingClientRect();
+ return [(v.getAttribute('src') || v.getAttribute('data-lazy-src') || '').split('/').pop(), { w: Math.round(r.width), h: Math.round(r.height), style: v.getAttribute('style') }];
+})));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(base, { 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 key = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clipper', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed', 'following');
+ }, [key, PK, notes]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ videoReqs.length = 0;
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await page.waitForSelector('.notes-feed:not([data-booting]) video.note-video', { timeout: 20000 });
+ const b = await boxes();
+ const ratio = (x) => x && x.h ? +(x.w / x.h).toFixed(2) : 0;
+ check('a 3:4 clip, measured ahead, paints at 3:4 full width', ratio(b['tall.mp4']) === 0.75 && b['tall.mp4'].w > 300 && /aspect-ratio:360\/480/.test(b['tall.mp4'].style || ''), JSON.stringify(b['tall.mp4']));
+ check('a 16:9 clip stays 16:9', Math.abs(ratio(b['wide.mp4']) - 1.78) < 0.02 && /aspect-ratio:640\/360/.test(b['wide.mp4'].style || ''), JSON.stringify(b['wide.mp4']));
+ check('a clip whose post gives imeta dim paints at that shape, without waiting on the file', Math.abs(ratio(b['told.mp4']) - 0.56) < 0.02 && /aspect-ratio:1080\/1920/.test(b['told.mp4'].style || ''), JSON.stringify(b['told.mp4']));
+ check('...capped at three quarters of the screen height', b['told.mp4'] && b['told.mp4'].h <= Math.ceil(844 * 0.75) + 1, JSON.stringify(b['told.mp4']));
+ check('a clip that will not load keeps the 16:9 box', /aspect-ratio:16\/9/.test(b['broken.mp4']?.style || ''), JSON.stringify(b['broken.mp4']));
+ await sleep(1500);
+ check('shapes are remembered on the device', await page.evaluate(() => (JSON.parse(localStorage.getItem('coinos-video-dims') || '[]')).length >= 3));
+ check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
+} catch (e) {
+ console.log('✗ threw', e.stack); ok = false;
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ feed videos paint at their own shape' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
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..e587e41
--- /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('ditto is the first host', (await import('../src/media-upload.js')).PUBLIC_MEDIA_SERVERS[0] === 'https://blossom.ditto.pub');
+
+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);