// Going live: a wallet with a coinos name opens Go live from the composer, // sees its camera, starts — the recording goes over the WebSocket to the live // server (coinosv3-server live/, run here beside the built app), the stream is // announced as its kind 30311 pointing at the HLS — and someone else opening // the stream's link watches it. Ending says so in a last 30311 carrying the // recording, and the recording is complete. // Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/) import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { naddrEncode } from 'nostr-tools/nip19'; import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node: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 PORT = 5304; const dir = mkdtempSync(tmpdir() + '/go-live-'); if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); // the registrar, as the live server asks it: the name is whoever is sending's let owner = null, nameCreated = null; const names = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => Response.json(new URL(req.url).pathname.endsWith('/alice') && owner ? { taken: true, pubkey: owner, created: nameCreated } : { taken: false }) }); process.env.LIVE_DATA = dir; process.env.NAMES_URL = names.url.origin; process.env.LIVE_BASE = `http://localhost:${PORT}/live`; const LIVE_PATH = process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js'; let live = await import(LIVE_PATH); // each socket stays with the server (module) that took it, so a "restart" // (a fresh import) leaves the old connections to the old one const sockMod = new WeakMap(); const websocket = { open: (ws) => { sockMod.set(ws, live); live.websocket.open?.(ws); }, message: (ws, m) => (sockMod.get(ws) || live).websocket.message?.(ws, m), close: (ws, c, r) => (sockMod.get(ws) || live).websocket.close?.(ws, c, r), }; const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => { const path = new URL(req.url).pathname; if (path.startsWith('/live/')) { if (path === '/live/ingest') try { owner = JSON.parse(atob(new URL(req.url).searchParams.get('auth'))).pubkey; } catch {} if (path === '/live/allowed') try { owner = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, ''))).pubkey; } catch {} return live.handle(req, srv); } const f = Bun.file('dist' + path); return path.includes('.') && 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', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', '--auto-select-desktop-capture-source=Entire screen', // LATENCY=1: both pages keep full speed (a background tab's timers crawl) ...(process.env.LATENCY ? ['--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'] : [])] }); // LATENCY=1: the "camera" is a clock — the time in ms (24 bits) as black and // white blocks — and the viewer reads it off the frame it shows: how far // behind the broadcaster a watcher sees, end to end const clockCam = () => { const real = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); navigator.mediaDevices.getUserMedia = async (c) => { if (!c || !c.video) return real(c); const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 720; const x = cv.getContext('2d'); setInterval(() => { const v = Date.now() % 16777216; x.fillStyle = '#808080'; x.fillRect(0, 0, 1280, 720); for (let i = 0; i < 24; i++) { x.fillStyle = (v >> i) & 1 ? '#fff' : '#000'; x.fillRect(160 + (i % 6) * 160, 120 + Math.floor(i / 6) * 120, 160, 120); } }, 1000 / 30); const s = cv.captureStream(30); if (c.audio) { const ac = new AudioContext(); const o = ac.createOscillator(); const d = ac.createMediaStreamDestination(); o.connect(d); o.start(); for (const t of d.stream.getAudioTracks()) s.addTrack(t); } return s; }; }; const readClock = () => { const v = document.querySelector('video.live-video'); if (!v || !v.videoWidth) return null; const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight; const x = cv.getContext('2d'); x.drawImage(v, 0, 0); const k = v.videoWidth / 1280; let n = 0; for (let i = 0; i < 24; i++) { const d = x.getImageData(Math.round((240 + (i % 6) * 160) * k), Math.round((180 + Math.floor(i / 6) * 120) * k), 1, 1).data; if (d[0] + d[1] + d[2] > 384) n |= 1 << i; } return ((Date.now() % 16777216) - n + 16777216) % 16777216; }; const relayStub = (fx) => { // what the relays held before a reload (a test can carry events across one) try { fx = [...fx, ...JSON.parse(sessionStorage.getItem('__fx') || '[]')]; } catch {} window.__published = []; const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && 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); if (/\/live\/ingest/.test(String(a[0]))) { (window.__ingest = window.__ingest || []).push(ws); return ws; } // the live server itself const send = ws.send.bind(ws); ws.send = (data) => { try { const m = JSON.parse(data); if (m[0] === 'REQ' && m[2]) { 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]); } catch {} return send(data); }; return ws; }; window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); }; const helpers = (page) => ({ click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x), waitText: async (x, ms = 20000) => { 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; }, waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; }, }); const lives = (page) => page.evaluate(() => window.__published.filter((e) => e.kind === 30311)); try { const page = await browser.newPage(); await page.setViewport({ width: 390, height: 844 }); await page.evaluateOnNewDocument(relayStub, []); if (process.env.LATENCY) await page.evaluateOnNewDocument(clockCam); // the registrar on the open internet is not asked: the name below is what the wallet holds await page.setRequestInterception(true); page.on('request', (r) => (/names\.coinos\.io|nwcpush/.test(r.url()) ? r.respond({ status: 404, body: '{}' }) : r.continue())); page.on('dialog', (d) => d.accept()); const errs = []; page.on('pageerror', (e) => errs.push(String(e))); const { click, waitText, waitFor } = helpers(page); await page.goto(`http://localhost:${PORT}/`, { 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'); const base = cacheKeyFor(mn + '\n'); await page.evaluate((k) => { localStorage.setItem(k + ':names', JSON.stringify({ byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } })); localStorage.setItem('btc-wallet-feed:' + k, 'following'); }, base); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive'); await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); console.log('[getting ready]'); await waitFor(() => !!document.querySelector('.feed-post-fab'), 15000); await page.evaluate(() => document.querySelector('.feed-post-fab').click()); check('the composer has a Go live button', await waitFor(() => !!document.querySelector('.go-live-tool'), 5000)); // a name claimed an hour ago can't stream yet: the screen says when it can nameCreated = Date.now() - 3600_000; await page.evaluate(() => document.querySelector('.go-live-tool').click()); check('a name claimed an hour ago is told it can go live a day after, Start off', await waitFor(() => !!document.querySelector('.go-live-refused') && /a day after you claimed it/.test(document.querySelector('.go-live-refused').textContent), 10000) && await page.evaluate(() => document.querySelector('.go-live-start').disabled)); await page.evaluate(() => document.querySelector('.live-page .chat-back, .live-page .iconbtn')?.click()); nameCreated = Date.now() - 2 * 86400_000; await waitFor(() => !document.querySelector('.go-live-start'), 5000); if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click()); await waitFor(() => !!document.querySelector('.go-live-tool'), 5000); await page.evaluate(() => document.querySelector('.go-live-tool').click()); check('Go live shows the camera', await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000)); check('...mirrored, as a front camera is', await page.evaluate(() => document.querySelector('video.bc-preview').classList.contains('mirror'))); await page.type('.go-live-title', 'Testing the live button'); console.log('\n[live]'); await page.evaluate(() => document.querySelector('.go-live-start').click()); check('starting opens the stream\'s page, with the clock and End', await waitFor(() => !!document.querySelector('.live-page .bc-bar .bc-end'), 40000)); const ann = (await lives(page)).at(-1); const tag = (e, k) => (e.tags.find((x) => x[0] === k) || [])[1]; check('the stream is announced: a 30311 of yours, live, pointing at its HLS', !!ann && tag(ann, 'status') === 'live' && /\/live\/[0-9a-f]{16}\/live\.m3u8$/.test(tag(ann, 'streaming')) && tag(ann, 'title') === 'Testing the live button' && ann.tags.some((x) => x[0] === 'p' && x[1] === ann.pubkey && x[3] === 'Host'), JSON.stringify(ann && ann.tags)); check('...by the key the server let in', ann && ann.pubkey === owner); check('the page shows you, not the stream seconds behind', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); return !!v && v.videoWidth > 0 && !document.querySelector('.live-page video.live-video:not(.bc-preview)'); }, 5000)); const hls = tag(ann, 'streaming'); const m3u8 = await (await fetch(hls)).text(); check('the server is making HLS of it, three segments in when it\'s announced', (m3u8.match(/\.ts/g) || []).length >= 3 && !/ENDLIST/.test(m3u8), (m3u8.match(/\.ts/g) || []).length + ' segments'); console.log('\n[camera ⇄ screen while live]'); const sending = () => page.evaluate(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; }); await page.evaluate(() => document.querySelector('.bc-switch').click()); check('Share screen, live: the stream carries on with the screen', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && /^screen/.test(t.label) && t.readyState === 'live'; }, 10000) && !!(await page.$('.bc-bar')) && await page.evaluate(() => document.querySelector('.bc-switch').textContent === 'Back to camera'), await sending()); await page.evaluate(() => document.querySelector('.bc-switch').click()); check('...and Back to camera returns to it, still live', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) && !!(await page.$('.bc-bar')), await sending()); await sleep(4000); const segsNow = ((await (await fetch(hls)).text()).match(/\.ts/g) || []).length; check('...the server still making HLS through the switches', segsNow >= 3, segsNow + ' segments in the window'); console.log('\n[someone watching]'); const viewer = await browser.newPage(); if (process.env.LATENCY === '2') await viewer.evaluateOnNewDocument(() => { window.__ev = []; const add = HTMLMediaElement.prototype.addEventListener; for (const n of ['error', 'ratechange', 'waiting', 'playing', 'emptied', 'abort', 'stalled']) document.addEventListener(n, (e) => { const v = e.target; window.__ev.push([Math.round(performance.now()), n, v.playbackRate, v.error && v.error.code + ':' + v.error.message, v.currentTime.toFixed(1)]); }, true); }); await viewer.setViewport({ width: 390, height: 844 }); await viewer.evaluateOnNewDocument(relayStub, [ann]); const v = helpers(viewer); await viewer.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: ann.pubkey, identifier: tag(ann, 'd') }), { waitUntil: 'domcontentloaded' }); check('the stream\'s link opens it', await v.waitFor(() => !!document.querySelector('.live-page'), 15000) && await v.waitText('Testing the live button', 3000)); const playsOk = await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000); if (process.env.LATENCY === '2') console.log(' events:', JSON.stringify(await viewer.evaluate(() => window.__ev.slice(0, 30)))); check('...and it plays', playsOk || await v.waitFor(() => { const x = document.querySelector('video.live-video'); return !!x && !x.paused && x.currentTime > 1 && x.videoWidth > 0; }, 30000), JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return x && { t: x.currentTime, w: x.videoWidth, err: x.error && x.error.code }; }))); if (process.env.LATENCY) { await viewer.evaluate(() => { window.__stalls = 0; document.querySelector('video.live-video').addEventListener('waiting', () => window.__stalls++); }); const lags = []; for (let i = 0; i < 30; i++) { const l = await viewer.evaluate(readClock); if (l != null && l < 60000) lags.push(l); await sleep(500); } if (process.env.LATENCY === '2') console.log(' player:', JSON.stringify(await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); const r = (tr) => [...Array(tr.length).keys()].map((i) => tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1)); return { t: x.currentTime.toFixed(1), seekable: r(x.seekable), buffered: r(x.buffered), rate: x.playbackRate, dur: x.duration }; }))); lags.sort((a, b) => a - b); console.log(` behind the broadcaster: median ${(lags[lags.length >> 1] / 1000).toFixed(1)} s, min ${(lags[0] / 1000).toFixed(1)} s, max ${(lags.at(-1) / 1000).toFixed(1)} s (${lags.length} reads), ${await viewer.evaluate(() => window.__stalls)} stall(s)`); } const st = await (await fetch(hls.replace('live.m3u8', 'stats'))).json(); check('the server counts the watcher', st.viewers >= 1, JSON.stringify(st)); await viewer.close(); console.log('\n[the server restarts mid-stream]'); // a fresh server, and the old one's shutdown: its streams end live = await import(LIVE_PATH + '?restart=1'); const oldId = tag(ann, 'd'); const sj = `${dir}/${oldId}/stream.json`; writeFileSync(sj, JSON.stringify({ ...JSON.parse(readFileSync(sj, 'utf8')), state: 'ended', ended: Date.now() })); const before = (await lives(page)).length; await page.evaluate(() => window.__ingest.forEach((w) => w.close())); check('the app comes back as a new stream and announces it', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n) .some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'live')), 40000, before)); const after = (await lives(page)).slice(before); const again = after.find((e) => tag(e, 'status') === 'live'); check('...at its new address, not the dead one', again && tag(again, 'd') !== oldId && tag(again, 'streaming').includes(tag(again, 'd')), JSON.stringify(after.map((e) => [tag(e, 'd'), tag(e, 'status')]))); const gone = after.find((e) => tag(e, 'd') === oldId); check('...the old one said to be over, with no recording', gone && tag(gone, 'status') === 'ended' && !gone.tags.some((x) => x[0] === 'recording')); const pl = await (await fetch(tag(again, 'streaming'))).text(); check('...and the new one plays', (pl.match(/\.ts/g) || []).length >= 3, (pl.match(/\.ts/g) || []).length + ' segments'); check('the streamer\'s own page follows to it', await waitFor((a) => !!document.querySelector('.live-page .bc-bar'), 5000)); console.log('\n[the end]'); await page.evaluate(() => document.querySelector('.bc-end').click()); check('End leaves the stream\'s page', await waitFor(() => !document.querySelector('.bc-bar'), 5000)); check('...and lets go of the camera', await page.evaluate(() => !document.querySelector('video.bc-preview'))); check('a last 30311 says it ended, with the recording', await waitFor(() => window.__published.some((e) => e.kind === 30311 && e.tags.some((x) => x[0] === 'status' && x[1] === 'ended') && e.tags.some((x) => x[0] === 'recording')), 8000)); const fin = (await lives(page)).at(-1); await sleep(1500); const rec = await (await fetch(tag(fin, 'recording'))).text(); check('...and the recording is whole', /ENDLIST/.test(rec) && (rec.match(/\.ts/g) || []).length >= 3, (rec.match(/\.ts/g) || []).length + ' segments'); console.log('\n[a stream the app lost]'); await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' }); await waitText('receive'); await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click()); if (!(await waitFor(() => !!document.querySelector('.feed-post-fab, .go-live-tool'), 15000))) console.log(' at:', await page.evaluate(() => location.pathname + ' ' + document.body.innerText.slice(0, 300).replace(/\s+/g, ' '))); if (!(await page.$('.go-live-tool'))) await page.evaluate(() => document.querySelector('.feed-post-fab').click()); await waitFor(() => !!document.querySelector('.go-live-tool'), 5000); await page.evaluate(() => document.querySelector('.go-live-tool').click()); await waitFor(() => { const v = document.querySelector('video.bc-preview'); return !!v && v.videoWidth > 0; }, 10000); await page.type('.go-live-title', 'Lost stream'); const n0 = (await lives(page)).length; await page.evaluate(() => document.querySelector('.go-live-start').click()); await waitFor((n) => window.__published.filter((e) => e.kind === 30311).length > n, 40000, n0); const lost = (await lives(page)).at(-1); // the tab goes (a reload): the relays still say live; the server waits a minute for the sender, then ends it await page.evaluate((e) => sessionStorage.setItem('__fx', JSON.stringify([e])), lost); await page.goto(`http://localhost:${PORT}/` + naddrEncode({ kind: 30311, pubkey: lost.pubkey, identifier: tag(lost, 'd') }), { waitUntil: 'domcontentloaded' }); await sleep(64000); check('opened, it says it\'s over (the server ended it), not live', await waitFor(() => !!document.querySelector('.live-page') && /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page .live-badge.live'), 30000), await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200))); check('...and its host gets End', await waitFor(() => !!document.querySelector('.live-end-stale'), 5000)); const n1 = (await lives(page)).length; await page.evaluate(() => document.querySelector('.live-end-stale').click()); check('End says it ended', await waitFor((n) => window.__published.filter((e) => e.kind === 30311).slice(n).some((e) => e.tags.some((x) => x[0] === 'status' && x[1] === 'ended')), 10000, n1)); const endedEv = (await lives(page)).at(-1); check('...the same stream, with its recording', tag(endedEv, 'd') === tag(lost, 'd') && /index\.m3u8$/.test(tag(endedEv, 'recording') || ''), JSON.stringify(endedEv.tags)); check('...and the End button goes', await waitFor(() => !document.querySelector('.live-end-stale'), 5000)); check('no page errors', !errs.length, errs.join(' | ')); } catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); names.stop(true); await sleep(300); if (!process.env.KEEP) rmSync(dir, { recursive: true, force: true }); else console.log('kept', dir); } console.log(ok ? '\n✅ going live: camera to stream to watcher' : '\n❌ failed'); process.exit(ok ? 0 : 1);