diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 08:16:57 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 08:17:11 -0700 |
| commit | a9cdc8a6fd7abd38a185f9d8828531374307cf41 (patch) | |
| tree | 04aa9267fcc9538f50ccf0319fa094e16a05934f /tools/go-live-test.js | |
| parent | 88194134aebe4a516f32afd39ac95cc757024e9b (diff) | |
| download | coinosv3-a9cdc8a6fd7abd38a185f9d8828531374307cf41.tar.gz coinosv3-a9cdc8a6fd7abd38a185f9d8828531374307cf41.zip | |
Live rings and going live. A face whose person is streaming wears a red ring (LIVE under the bigger ones) and opens the stream. Go live (the composer's red dot, or your profile) sends your camera or screen to coinos's live server (coinosv3-server live/: the recording over a WebSocket, HLS out), announces it as your kind 30311 once three segments exist (Chrome's own player won't start a live playlist with fewer), refreshes it each minute with how many watch, reconnects to the same stream if the link drops, and ends it with its recording. Needs a coinos name. Also: the service worker leaves /live/ and /vmedia/hls alone (it froze a live playlist at its first copy).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'tools/go-live-test.js')
| -rw-r--r-- | tools/go-live-test.js | 150 |
1 files changed, 150 insertions, 0 deletions
diff --git a/tools/go-live-test.js b/tools/go-live-test.js new file mode 100644 index 0000000..82d6c7c --- /dev/null +++ b/tools/go-live-test.js @@ -0,0 +1,150 @@ +// 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 } 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; +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 } : { taken: false }) }); +process.env.LIVE_DATA = dir; +process.env.NAMES_URL = names.url.origin; +process.env.LIVE_BASE = `http://localhost:${PORT}/live`; +const live = await import(process.env.LIVE_SERVER || '../../coinosv3-server/live/server.js'); +const server = Bun.serve({ port: PORT, websocket: live.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 {} + 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'] }); + +const relayStub = (fx) => { + 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]))) 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, []); + // 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)); + 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[someone watching]'); + const viewer = await browser.newPage(); + 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)); + check('...and it plays', 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 }; }))); + 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 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'); + 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); |
