aboutsummaryrefslogtreecommitdiffstats
path: root/tools/go-live-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/go-live-test.js')
-rw-r--r--tools/go-live-test.js76
1 files changed, 73 insertions, 3 deletions
diff --git a/tools/go-live-test.js b/tools/go-live-test.js
index 990dc1a..c3fc8e3 100644
--- a/tools/go-live-test.js
+++ b/tools/go-live-test.js
@@ -4,7 +4,8 @@
// 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/)
+// Run: bun tools/go-live-test.js (LIVE_SERVER=path to coinosv3-server/live/server.js; NO_BUILD=1 to reuse dist/;
+// LIVE_CONFIG=a json with {sfuAppId, sfuSecret} to watch over WebRTC — the real Cloudflare SFU)
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { naddrEncode } from 'nostr-tools/nip19';
@@ -44,7 +45,39 @@ const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => {
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'] });
+ 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)
@@ -84,6 +117,7 @@ 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()));
@@ -140,15 +174,41 @@ try {
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));
- 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),
+ 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 }; })));
+ // LIVE_CONFIG with the SFU's key ({sfuAppId, sfuSecret}): the app's viewer
+ // watches over WebRTC through Cloudflare; without it, HLS
+ const overRtc = await viewer.evaluate(() => { const x = document.querySelector('video.live-video'); return !!x && x.srcObject instanceof MediaStream; });
+ check(process.env.LIVE_CONFIG ? '...over WebRTC (Cloudflare\'s SFU)' : '...over HLS (no SFU key here)', overRtc === !!process.env.LIVE_CONFIG);
+ 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();
@@ -209,6 +269,16 @@ try {
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));
+
+ console.log('\n[deleting your recording]');
+ check('the streamer has Delete recording on their ended stream', await waitFor(() => !!document.querySelector('.live-delete'), 25000));
+ const n2 = (await lives(page)).length;
+ await page.evaluate(() => document.querySelector('.live-delete').click());
+ check('...deleting it says so, and the button goes', await waitFor(() => /deleted this recording/.test(document.body.innerText) && !document.querySelector('.live-delete'), 8000),
+ await page.evaluate(() => document.querySelector('.live-page')?.innerText.slice(0, 200)));
+ check('...the server has let it go (410)', (await fetch(tag(lost, 'streaming').replace('live.m3u8', 'index.m3u8'))).status === 410);
+ const redone = (await lives(page)).slice(n2).at(-1);
+ check('...and the stream is announced again, ended, with no recording', !!redone && tag(redone, 'd') === tag(lost, 'd') && tag(redone, 'status') === 'ended' && !redone.tags.some((x) => x[0] === 'recording'), JSON.stringify(redone && redone.tags));
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);