aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-13 13:04:52 +0000
committerAdam Soltys <[email protected]>2026-10-13 13:04:52 +0000
commitf3d03c642bef06b3afc8a3038136c5e0fc1e21f7 (patch)
tree11f43f80c14d9c712d12ba86820ca38fa51b9bf1 /tools
parentcd058495e11d76bb5afe4fe85b5131adaa387ca1 (diff)
downloadcoinosv3-f3d03c642bef06b3afc8a3038136c5e0fc1e21f7.tar.gz
coinosv3-f3d03c642bef06b3afc8a3038136c5e0fc1e21f7.zip
Profiles show boosts as boosts, and their saved copy keeps each post's kind
A boost that reached a profile from the feed was saved as a kind 1 note, so reopened it drew its raw JSON as a reply; video events and polls lost their kind and tags the same way. The saved copy now keeps kinds (boosts whole), old copies are dropped, and profiles ask for boosts too. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
Diffstat (limited to 'tools')
-rw-r--r--tools/profile-repost-test.js76
1 files changed, 76 insertions, 0 deletions
diff --git a/tools/profile-repost-test.js b/tools/profile-repost-test.js
new file mode 100644
index 0000000..7825efe
--- /dev/null
+++ b/tools/profile-repost-test.js
@@ -0,0 +1,76 @@
+// A profile lists the person's boosts, as a feed does: the note under
+// "<name> reposted", never the boost's raw JSON. And its saved copy keeps
+// each post's kind: a boost reopened from cache (relays silent) is still a
+// boost, a video event still has its clip — they were all saved as kind 1
+// notes, so a boost came back as "reply · {"content":…}".
+// Run: bun tools/profile-repost-test.js
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const ME = key(), THEM = key();
+const now = Math.floor(Date.now() / 1000);
+const THEIRS = finalizeEvent({ kind: 1, created_at: now - 900, tags: [], content: 'their offer to grow nostr' }, THEM.sk);
+const EVS = [
+ finalizeEvent({ kind: 1, created_at: now - 100, tags: [], content: 'my own words here' }, ME.sk),
+ finalizeEvent({ kind: 6, created_at: now - 200, tags: [['e', THEIRS.id, '', 'mention'], ['p', THEM.pk]], content: JSON.stringify(THEIRS) }, ME.sk),
+ finalizeEvent({ kind: 21, created_at: now - 300, tags: [['title', 'My clip title'], ['imeta', 'url https://example.invalid/v/clipfile', 'm video/mp4', 'dim 180x320']], content: 'about my clip' }, ME.sk),
+];
+const PROFILES = [
+ finalizeEvent({ kind: 0, created_at: now - 10, tags: [], content: JSON.stringify({ name: 'Booster' }) }, ME.sk),
+ finalizeEvent({ kind: 0, created_at: now - 10, tags: [], content: JSON.stringify({ name: 'Offerer' }) }, THEM.sk),
+];
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); return new Response(html, { headers: { 'content-type': 'text/html' } }); } });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((fx) => {
+ const Real = window.WebSocket;
+ const fits = (ev, f) => (!f.kinds || f.kinds.includes(ev.kind)) && (!f.authors || f.authors.includes(ev.pubkey)) && (!f.ids || f.ids.includes(ev.id)) && !Object.keys(f).some((k) => k.startsWith('#'));
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { const fs = m.slice(2); setTimeout(() => { if (!sessionStorage.getItem('silent')) for (const ev of fx.filter((e) => fs.some((f) => fits(e, f)))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })); ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })); }, 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, [...EVS, ...PROFILES]);
+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.toLowerCase())) return true; await sleep(250); } return false; };
+const rows = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed [data-zap-post]')].map((r) => ({ text: r.innerText, post: r.getAttribute('data-zap-post'), video: !!r.querySelector('video.note-video') })));
+async function look(label) {
+ console.log('\n[' + label + ']');
+ check('the profile shows its posts', await waitText('my own words here', 20000));
+ await sleep(1200);
+ const r = await rows();
+ const boost = r.find((x) => x.text.includes('their offer to grow nostr'));
+ check('the boost is the note it boosts, under "Booster reposted"', !!boost && /Booster reposted/.test(boost.text) && boost.post === THEIRS.id, JSON.stringify(boost));
+ check('…as Offerer\'s post, not a reply', !!boost && /Offerer/.test(boost.text) && !/\breply\b/i.test(boost.text.split('\n').slice(0, 4).join(' ')));
+ check('no raw JSON anywhere', !r.some((x) => /"content":|"sig":/.test(x.text)), r.map((x) => x.text.slice(0, 40)).join(' | '));
+ const clip = r.find((x) => x.text.includes('My clip title'));
+ check('the video event is still a clip', !!clip && clip.video, JSON.stringify(clip));
+}
+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');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ await page.goto(server.url.href + npubEncode(ME.pk), { waitUntil: 'domcontentloaded' });
+ await look('from the relays');
+ await sleep(1500); // (the page cache is written behind a debounce)
+ const nav = await page.evaluate(() => history.state);
+ await page.evaluate(() => sessionStorage.setItem('silent', '1'));
+ // reopened in a booted app (a cold deep link opens before the saved pages are read)
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000); await sleep(1500);
+ await page.evaluate(([st, u]) => { history.pushState(st, '', u); dispatchEvent(new PopStateEvent('popstate', { state: st })); }, [nav, '/' + npubEncode(ME.pk)]);
+ await look('reopened from its saved copy, the relays silent');
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a profile shows boosts as boosts, and keeps them so' : '\n❌ failed');
+process.exit(ok ? 0 : 1);