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/live-stream-test.js | |
| 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/live-stream-test.js')
| -rw-r--r-- | tools/live-stream-test.js | 44 |
1 files changed, 38 insertions, 6 deletions
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()); |
