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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
|
// 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'] });
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, []);
// 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();
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 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);
|