diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 23:46:18 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 23:46:18 -0700 |
| commit | aa3fecd4f0dc39550c6ad44a19736f820201d0a3 (patch) | |
| tree | fac2819d695f881fbeb73264907445aed1e3e596 /tools | |
| parent | 1f0b1cd2af2c2391d6706f6194bdb65c85f14ca6 (diff) | |
| download | coinosv3-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.js | 74 | ||||
| -rw-r--r-- | tools/live-reactions-test.js | 13 | ||||
| -rw-r--r-- | tools/live-stream-test.js | 44 |
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()); |
