1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
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);
|