aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 23:46:18 -0700
committerAdam Soltys <[email protected]>2026-10-18 23:46:18 -0700
commitaa3fecd4f0dc39550c6ad44a19736f820201d0a3 (patch)
treefac2819d695f881fbeb73264907445aed1e3e596 /tools
parent1f0b1cd2af2c2391d6706f6194bdb65c85f14ca6 (diff)
downloadcoinosv3-aa3fecd4f0dc39550c6ad44a19736f820201d0a3.tar.gz
coinosv3-aa3fecd4f0dc39550c6ad44a19736f820201d0a3.zip
A stream's chat is the app's chat: each comment is a bubble like a DM's or a group's — tap for the emoji row, reply, zap or copy; reactions (kind 7, tagged with the stream and the comment, one per person, deletable) and zap totals show on the comment for every viewer; the zap goes to the commenter, for that comment, on that stream. The composer is the one the other chats use (drafts kept per stream). A reaction no relay took is taken back. (Begun by Codex; finished here.)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'tools')
-rw-r--r--tools/live-chat-actions-test.js74
-rw-r--r--tools/live-reactions-test.js13
-rw-r--r--tools/live-stream-test.js44
3 files changed, 125 insertions, 6 deletions
diff --git a/tools/live-chat-actions-test.js b/tools/live-chat-actions-test.js
new file mode 100644
index 0000000..4e4d4ff
--- /dev/null
+++ b/tools/live-chat-actions-test.js
@@ -0,0 +1,74 @@
+// Real live-chat controls with local relays and a fake payment handler.
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+const app = await Bun.file('src/app.js').text();
+const dom = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import {messagesFeature} from './src/features/messages.js';
+import {getPublicKey} from 'nostr-tools/pure';
+import {liveReactions, zapAddrOf} from './src/live.js';
+${dom}
+const sk = new Uint8Array(32).fill(1), me = getPublicKey(sk), fan = '2'.repeat(64);
+const ev = {id:'3'.repeat(64),pubkey:fan,kind:1311,created_at:1,content:'A viewer comment',tags:[]};
+const w = {a:'30311:'+'4'.repeat(64)+':stream',lines:new Map([[ev.id,ev]]),actions:new Map(),reacting:new Set(),relays:['wss://fixture'],stops:[],sheet:null};
+const ui = {}, calls = [], toasts = [];
+window.__published=[]; window.__publishOK=true;
+let feature;
+const wallet = {mnemonic:'test',nostr:{pk:me,sk},nostrRelays:()=>[],loadFeatureState:()=>({}),saveFeatureState(){},registerCacheExtension(){}};
+function render(){if(feature) document.querySelector('#app').replaceChildren(feature.testLine(ev,w,liveReactions([...w.actions.values()])), ...(w.sheet?[feature.testSheet(w)]:[]));}
+feature = messagesFeature({h,ui,wallet,render,toast:s=>toasts.push(s),zapDefaultSat:()=>21,zapSound:()=>false,
+ hook(name,...args){if(name==='arkReady')return true;if(name==='zapNpub'){calls.push(args);return true;}return null;}});
+feature.testStart(w,ev); render();
+window.test={ui,w,ev,me,fan,calls,toasts,render,addr:()=>zapAddrOf(ev.id),self:()=>{ev.pubkey=me;render();},other:()=>{ev.pubkey=fan;render();},settle:()=>feature.zapSettled(ev.id,true,21)};
+`;
+const built = await Bun.build({entrypoints:['test-entry'],target:'browser',plugins:[{name:'test',setup(b){
+ b.onResolve({filter:/^test-entry$/},()=>({path:'entry',namespace:'test'}));
+ b.onLoad({filter:/.*/,namespace:'test'},()=>({contents:entry,loader:'js',resolveDir:process.cwd()}));
+ b.onLoad({filter:/src\/features\/messages\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text()).replace("id: 'messages',", "id: 'messages', testLine: liveLine, testSheet: liveMessageSheet, testStart(w,e){liveWatch=w;setZapAddr(e.id,w.a);},")}));
+ b.onLoad({filter:/src\/nostr\.js$/},async({path})=>({loader:'js',contents:(await Bun.file(path).text())
+ .replace(/export async function publishOn\(relays, evt, \{[^}]*\} = \{\}\) \{/, (m) => m + ' window.__published.push(evt); return window.__publishOK;')
+ .replace('export function subscribeOn(relays, filter, onEvent, extra = {}) {','export function subscribeOn(relays, filter, onEvent, extra = {}) { return () => {};')
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {','export async function queryOn(relays, filter, maxWait = 1500) { return [];') }));
+}}]});
+assert(built.success,built.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 built.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 pause=ms=>new Promise(r=>setTimeout(r,ms));
+try {
+ await page.setViewport({width:390,height:844});
+ await page.goto(server.url.href);
+ await page.waitForSelector('.chat-zap', {timeout:5000}).catch(async e => { throw new Error(errors.join(' | ') || e.message); });
+ await page.click('.chat-zap');
+ assert(await page.evaluate(()=>test.calls.length===1 && test.calls[0][0]===test.fan && test.calls[0][2]===test.ev.id && test.addr()===test.w.a),'zap must target the commenter, comment and stream');
+ await page.evaluate(()=>{test.settle();test.self();});
+ assert(!(await page.$('.chat-zap')),'no self-zap button');
+ await page.evaluate(()=>test.other());
+ await page.click('.chat-bubble');
+ await page.waitForSelector('.msg-sheet-emojis');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForSelector('.chat-react.on:not(.zap-tally)');
+ const reaction=await page.evaluate(()=>window.__published.find(e=>e.kind===7));
+ assert(reaction.tags.some(t=>t[0]==='k'&&t[1]==='1311'));
+ await page.click('.chat-react.on:not(.zap-tally)'); await pause(100);
+ assert(!(await page.$('.chat-react.on:not(.zap-tally)')),'tapping again removes our reaction');
+ await page.click('.chat-bubble');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForSelector('.chat-react.on:not(.zap-tally)');
+ assert(await page.evaluate(()=>new Set(window.__published.filter(e=>e.kind===7).map(e=>e.id)).size===2),'immediately re-adding a removed reaction must have a new event id');
+ await page.click('.chat-react.on:not(.zap-tally)'); await pause(100);
+ await page.evaluate(()=>window.__publishOK=false);
+ await page.click('.chat-bubble');
+ await page.evaluate(()=>[...document.querySelectorAll('.msg-sheet-emojis button')].find(b=>b.textContent==='❤️').click());
+ await page.waitForFunction(() => !document.querySelector('.chat-react.on:not(.zap-tally)'), {timeout:3000});
+ assert(await page.evaluate(()=>test.toasts.length>0));
+ const box = await (await page.$('.chat-zap')).boundingBox();
+ await page.mouse.move(box.x+box.width/2,box.y+box.height/2); await page.mouse.down(); await pause(650); await page.mouse.up();
+ assert(await page.evaluate(()=>test.ui.zapSetup.pk===test.fan && test.ui.zapSetup.eventId===test.ev.id && test.ui.zapSetup.edit),'hold opens the commenter’s zap amount settings');
+ assert.deepEqual(errors,[]);
+ console.log('✓ Live chat zap recipient/event/stream, no self-zaps, emoji actions, toggle/re-add and rejection rollback');
+} finally {await browser.close();server.stop(true);}
diff --git a/tools/live-reactions-test.js b/tools/live-reactions-test.js
new file mode 100644
index 0000000..b142528
--- /dev/null
+++ b/tools/live-reactions-test.js
@@ -0,0 +1,13 @@
+import assert from 'node:assert/strict';
+import { liveReactions } from '../src/live.js';
+const one = {kind:7,id:'one',pubkey:'alice',created_at:1,content:'❤️',tags:[['e','parent'],['e','comment']]};
+const two = {...one,id:'two',created_at:2,content:'👍'};
+const other = {...one,id:'other',pubkey:'bob'};
+const del = {kind:5,id:'delete',pubkey:'alice',created_at:3,tags:[['e','one'],['e','two']]};
+assert.equal(liveReactions([two, one]).get('comment').get('alice').content,'👍');
+assert.equal(liveReactions([one, one, other]).get('comment').size,2);
+assert.equal(liveReactions([del, two, one, other]).get('comment').size,1);
+assert.equal(liveReactions([{...del,pubkey:'mallory'},two]).get('comment').size,1);
+assert.equal(liveReactions([one,two,{...del,tags:[['e','one']]}]).get('comment').get('alice').id,'two');
+assert.equal(liveReactions([two,{...two,id:'zzz'}]).get('comment').get('alice').id,'zzz');
+console.log('✓ Live reaction deduplication, replacement, out-of-order deletion and author validation');
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
index 7ca4e4d..0deb48c 100644
--- a/tools/live-stream-test.js
+++ b/tools/live-stream-test.js
@@ -55,17 +55,23 @@ const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content:
['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'],
['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'],
] }, SERVICE.sk);
-const FIXTURES = [live, stale, past, pastPage, chat, zap];
+const reaction = finalizeEvent({kind:7,created_at:now-10,content:'👍',tags:[['e',chat.id],['p',FAN.pk],['k','1311']]},HOST.sk);
+const messageZap = finalizeEvent({kind:9737,created_at:now-9,content:'nice comment',tags:[['e',chat.id],['p',FAN.pk],['P',HOST.pk],['amount','5'],['network','regtest']]},HOST.sk);
+const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap];
const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk);
// the site as built (the split build: hls.js arrives as its own chunk)
if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
-const server = Bun.serve({ port: PORT, fetch: async (req) => {
+const server = Bun.serve({ port: PORT, fetch: async (req, srv) => {
+ if (new URL(req.url).pathname === "/relay" && srv.upgrade(req)) return;
const path = new URL(req.url).pathname;
if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } });
const f = Bun.file('dist' + path);
return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
-} });
+}, websocket: { message(ws, data) {
+ const m = JSON.parse(String(data));
+ if (m[0] === 'EVENT') ws.send(JSON.stringify(['OK', m[1].id, true, '']));
+} } });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
// the relays: they hold the fixtures, answer by the filter, and keep what's published
@@ -79,7 +85,7 @@ const relayStub = (fx) => {
&& Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
const Real = window.WebSocket;
window.WebSocket = function (...a) {
- const ws = new Real(...a);
+ const ws = new Real('ws://localhost:5302/relay'); // local fixture relay; never publish test events outside the test
const send = ws.send.bind(ws);
ws.send = (data) => {
try {
@@ -90,7 +96,8 @@ const relayStub = (fx) => {
const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e)));
if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
}
- if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]);
+ if (m[0] === 'EVENT' && m[1] && !window.__published.some(e => e.id === m[1].id)) { window.__published.push(m[1]); setTimeout(() => window.__push(m[1]), 0); }
+ if (m[0] === 'CLOSE') for (let i = subs.length - 1; i >= 0; i--) if (subs[i][0] === ws && subs[i][1] === m[1]) subs.splice(i, 1);
} catch {}
return send(data);
};
@@ -179,13 +186,38 @@ try {
check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM));
check('the chat shows what was said', await waitText('hello from the chat', 8000));
check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
- await page.focus('.live-input');
+ const rowSel = '.live-message[data-key="' + chat.id + '"]';
+ check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft'));
+ check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id));
+ check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id));
+ await page.click(rowSel + ' .chat-bubble');
+ check('tapping the bubble opens the shared emoji/action sheet', await waitFor(() => !!document.querySelector('.msg-sheet-emojis'), 2000));
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find(b => b.textContent === '❤️').click());
+ check('a reaction is published against the comment and its author', await waitFor(id => window.__published.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, chat.id));
+ const myReaction = await page.evaluate(id => window.__published.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), chat.id);
+ check('reaction carries the stream and kind for other clients', myReaction?.tags.some(t => t[0] === 'a' && t[1] === A) && myReaction.tags.some(t => t[0] === 'p' && t[1] === FAN.pk) && myReaction.tags.some(t => t[0] === 'k' && t[1] === '1311'));
+ check('our reaction highlights immediately', await waitFor(id => !!document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
+ await page.click(rowSel + ' .chat-react.on');
+ check('tapping it again publishes its deletion', await waitFor(id => window.__published.some(e => e.kind === 5 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, myReaction.id));
+ check('the removed reaction disappears', await waitFor(id => !document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
+ const remoteReaction = finalizeEvent({kind:7,created_at:now+1,content:'🔥',tags:[['e',chat.id],['p',FAN.pk]]},HOST.sk);
+ await page.evaluate(e => window.__push(e), remoteReaction);
+ check('a new remote reaction updates without reloading', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('🔥'), 3000, chat.id));
+ // The existing zap button must select this fan's npub, not the host.
+ const zapButton = await page.$(rowSel + ' .chat-zap');
+ check('the comment has the same one-tap zap control as a DM', !!zapButton);
+ await page.click(rowSel + ' .chat-bubble');
+ await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-item')].find(b => b.textContent.includes('Reply')).click());
+ check('Reply opens the group-chat quote bar', await waitFor(() => !!document.querySelector('.live-page .reply-bar'), 2000));
+ await page.focus('.live-page #msg-draft');
await page.keyboard.type('first time here');
await page.keyboard.press('Enter');
check('a line typed in the chat shows at once', await waitText('first time here', 3000));
const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311));
check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags));
+ check('reply names the parent comment', !!sent && sent.tags.some(t => t[0] === 'e' && t[1] === chat.id) && sent.tags.some(t => t[0] === 'p' && t[1] === FAN.pk));
check('the player keeps playing through the renders', await waitFor(playing, 3000));
+ if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(400); await page.screenshot({ path: process.env.SHOT }); }
console.log('\n[ways out]');
await page.evaluate(() => document.querySelector('.live-page .chat-avatar.mini.live-ring').click());