aboutsummaryrefslogtreecommitdiffstats
path: root/tools/live-stream-test.js
blob: 440be36bf7d7355c182b695aeade1927a42421bb (plain) (blame)
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
// Live streams (NIP-53): a feed shows its people's streams in a row above
// the posts (a stream gone quiet for an hour is over), a tap opens the
// stream's page — the player playing the HLS stream, the chat and zaps as
// they come, a chat line sent as a kind 1311 — and the address bar names it
// (/naddr1…). A post linking the stream shows it as a card, the host's
// profile says they're on, Back stops the player, the browser without HLS
// of its own plays it through hls.js, and a zap carries the stream's `a`.
// Run: bun tools/live-stream-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { naddrEncode } from 'nostr-tools/nip19';
import { buildZapRequest } from '../src/lnurl.js';
import { setZapAddr } from '../src/live.js';
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 = 5302;
const now = Math.floor(Date.now() / 1000);
const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
const HOST = key(), SERVICE = key(), FAN = key(), STALE = key();

// a stream: 30 s of HLS, its playlist without an end (as a live one is)
const dir = mkdtempSync(tmpdir() + '/live-stream-');
Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440',
  '-t', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '50', '-c:a', 'aac', '-f', 'hls', '-hls_time', '2', '-hls_list_size', '0', dir + '/index.m3u8']);
writeFileSync(dir + '/index.m3u8', readFileSync(dir + '/index.m3u8', 'utf8').replace('#EXT-X-ENDLIST\n', ''));
const STREAM = `http://localhost:${PORT}/live/index.m3u8`;

// zap.stream's shape: the service signs, the streamer is the Host
const live = finalizeEvent({ kind: 30311, created_at: now - 30, content: '', tags: [
  ['d', 'stream-1'], ['title', 'Building coinos live'], ['summary', 'Hacking on the wallet'], ['status', 'live'],
  ['streaming', STREAM], ['current_participants', '12'], ['starts', String(now - 600)],
  ['p', HOST.pk, '', 'Host'], ['relays', 'wss://relay.example.com'],
] }, SERVICE.sk);
const A = `30311:${SERVICE.pk}:stream-1`;
const NADDR = naddrEncode({ kind: 30311, pubkey: SERVICE.pk, identifier: 'stream-1' });
// still says live, but nobody has refreshed it in two hours
const stale = finalizeEvent({ kind: 30311, created_at: now - 7200, content: '', tags: [
  ['d', 'old'], ['title', 'Forgotten stream'], ['status', 'live'], ['streaming', STREAM], ['p', HOST.pk, '', 'host'],
] }, STALE.sk);
const chat = finalizeEvent({ kind: 1311, created_at: now - 20, content: 'hello from the chat', tags: [['a', A, '', 'root']] }, FAN.sk);
const zapReq = finalizeEvent({ kind: 9734, created_at: now - 15, content: 'great stream', tags: [['p', HOST.pk], ['a', A], ['amount', '21000']] }, FAN.sk);
const zap = finalizeEvent({ kind: 9735, created_at: now - 15, content: '', tags: [['p', HOST.pk], ['a', A], ['bolt11', 'lnbc210n1invalid'], ['description', JSON.stringify(zapReq)], ['P', FAN.pk]] }, SERVICE.sk);
const post = finalizeEvent({ kind: 1, created_at: now - 100, content: 'come watch nostr:' + NADDR, tags: [] }, HOST.sk);
// yesterday's stream, over, its recording kept
const past = finalizeEvent({ kind: 30311, created_at: now - 86000, content: '', tags: [
  ['d', 'stream-0'], ['title', 'Yesterday on coinos'], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
  ['starts', String(now - 90000)], ['ends', String(now - 86000)], ['p', HOST.pk, '', 'Host'],
] }, SERVICE.sk);
// one kept on a video site: its "recording" is a web page
const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content: '', tags: [
  ['d', 'stream-p'], ['title', 'Zelda on Rumble'], ['status', 'ended'], ['streaming', STREAM], ['recording', 'https://rumble.com/v7gt2va-zelda.html'],
  ['ends', String(now - 50000)], ['p', HOST.pk, '', 'Host'],
] }, SERVICE.sk);
const reaction = finalizeEvent({kind:7,created_at:now-10,content:'👍',tags:[['e',chat.id],['p',FAN.pk],['k','1311']]},HOST.sk);
const messageZap = finalizeEvent({kind:9737,created_at:now-9,content:'nice comment',tags:[['e',chat.id],['p',FAN.pk],['P',HOST.pk],['amount','5'],['network','regtest']]},HOST.sk);
const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap];
const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk);

// the site as built (the split build: hls.js arrives as its own chunk)
if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
const server = Bun.serve({ port: PORT, fetch: async (req, srv) => {
  if (new URL(req.url).pathname === "/relay" && srv.upgrade(req)) return;
  const path = new URL(req.url).pathname;
  if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } });
  const f = Bun.file('dist' + path);
  return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
}, websocket: { message(ws, data) {
  const m = JSON.parse(String(data));
  if (m[0] === 'EVENT') ws.send(JSON.stringify(['OK', m[1].id, true, '']));
} } });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });

// the relays: they hold the fixtures, answer by the filter, and keep what's published
const relayStub = (fx) => {
  window.__published = []; window.__reqs = [];
  const subs = [];
  // a new event, to every open subscription it matches (a relay's live feed)
  window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); };
  const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
    && (!f.since || e.created_at >= f.since)
    && 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('ws://localhost:5302/relay'); // local fixture relay; never publish test events outside the test
    const send = ws.send.bind(ws);
    ws.send = (data) => {
      try {
        const m = JSON.parse(data);
        if (m[0] === 'REQ' && m[2]) {
          window.__reqs.push([a[0], ...m.slice(2)]);
          subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, limit: undefined }))]);
          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.some(e => e.id === m[1].id)) { window.__published.push(m[1]); setTimeout(() => window.__push(m[1]), 0); }
        if (m[0] === 'CLOSE') for (let i = subs.length - 1; i >= 0; i--) if (subs[i][0] === ws && subs[i][1] === m[1]) subs.splice(i, 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 newPage = async (noHls = false) => {
  const page = await browser.newPage();
  await page.setViewport({ width: 390, height: 844 });
  await page.evaluateOnNewDocument(relayStub, FIXTURES);
  // Firefox: no HLS of its own
  if (noHls) await page.evaluateOnNewDocument(() => {
    const real = HTMLMediaElement.prototype.canPlayType;
    HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : real.call(this, t); };
  });
  const errs = [];
  page.on('pageerror', (e) => errs.push(String(e)));
  page.errs = errs;
  return page;
};
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 playing = () => { const v = document.querySelector('video.live-video'); return !!v && !v.paused && v.currentTime > 1 && v.videoWidth > 0; };

try {
  console.log('[a zap on a stream names it]');
  setZapAddr(live.id, A);
  const req = await buildZapRequest({ amountMsat: 21000, relays: ['wss://r'], recipientPk: HOST.pk, eventId: live.id, signFn: (e) => e });
  check('the zap request carries the stream\'s a tag beside its e', req.tags.some((x) => x[0] === 'a' && x[1] === A) && req.tags.some((x) => x[0] === 'e' && x[1] === live.id));

  const page = await newPage();
  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, pk, ns]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Stream Host', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, HOST.pk, [post]]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive');
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());

  console.log('\n[the feed]');
  check('the Live chip sits after Videos, a red dot on it while a followed host streams', await waitFor(() => {
    const modes = [...document.querySelectorAll('.media-mode')].map((b) => b.dataset.mode);
    return modes.indexOf('live') === modes.indexOf('videos') + 1 && !!document.querySelector('.media-mode[data-mode="live"] .live-dot');
  }, 15000));
  check('...and no streams above the posts', !(await page.evaluate(() => document.querySelector('.live-grid, .live-strip'))));
  await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
  check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 15000));
  const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid:not(.live-past) .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
  check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile));
  check('past streams follow, the recording kept, its end time shown', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText) && /RECORDING/.test(t.innerText)), 8000)
    && await page.evaluate(() => /Past streams/.test(document.body.innerText)));
  check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream'))));
  await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
  check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000));
  check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000));
  await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click());
  check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000));
  await page.goBack();
  await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000);
  await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
  await waitFor(() => !!document.querySelector('.live-grid:not(.live-past) .live-tile'), 5000);

  console.log('\n[the stream\'s page]');
  await page.evaluate(() => document.querySelector('.live-grid:not(.live-past) .live-tile').click());
  check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname));
  check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; })));
  // a stream that's on plays through hls.js (kept near the live edge), not Chrome's own player
  check('...through hls.js, kept near the live edge', await page.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
  check('the chat shows what was said', await waitText('hello from the chat', 8000));
  check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
  const rowSel = '.live-message[data-key="' + chat.id + '"]';
  check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft'));
  check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id));
  check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id));
  await page.click(rowSel + ' .chat-bubble');
  check('tapping the bubble opens the shared emoji/action sheet', await waitFor(() => !!document.querySelector('.msg-sheet-emojis'), 2000));
  await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find(b => b.textContent === '❤️').click());
  check('a reaction is published against the comment and its author', await waitFor(id => window.__published.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, chat.id));
  const myReaction = await page.evaluate(id => window.__published.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'e' && t[1] === id)), chat.id);
  check('reaction carries the stream and kind for other clients', myReaction?.tags.some(t => t[0] === 'a' && t[1] === A) && myReaction.tags.some(t => t[0] === 'p' && t[1] === FAN.pk) && myReaction.tags.some(t => t[0] === 'k' && t[1] === '1311'));
  check('our reaction highlights immediately', await waitFor(id => !!document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
  await page.click(rowSel + ' .chat-react.on');
  check('tapping it again publishes its deletion', await waitFor(id => window.__published.some(e => e.kind === 5 && e.tags.some(t => t[0] === 'e' && t[1] === id)), 5000, myReaction.id));
  check('the removed reaction disappears', await waitFor(id => !document.querySelector('.live-message[data-key="'+id+'"] .chat-react.on'), 3000, chat.id));
  const remoteReaction = finalizeEvent({kind:7,created_at:now+1,content:'🔥',tags:[['e',chat.id],['p',FAN.pk]]},HOST.sk);
  await page.evaluate(e => window.__push(e), remoteReaction);
  check('a new remote reaction updates without reloading', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('🔥'), 3000, chat.id));
  // The existing zap button must select this fan's npub, not the host.
  const zapButton = await page.$(rowSel + ' .chat-zap');
  check('the comment has the same one-tap zap control as a DM', !!zapButton);
  await page.click(rowSel + ' .chat-bubble');
  await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-item')].find(b => b.textContent.includes('Reply')).click());
  check('Reply opens the group-chat quote bar', await waitFor(() => !!document.querySelector('.live-page .reply-bar'), 2000));
  await page.focus('.live-page #msg-draft');
  await page.keyboard.type('first time here');
  await page.keyboard.press('Enter');
  check('a line typed in the chat shows at once', await waitText('first time here', 3000));
  const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311));
  check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags));
  check('reply names the parent comment', !!sent && sent.tags.some(t => t[0] === 'e' && t[1] === chat.id) && sent.tags.some(t => t[0] === 'p' && t[1] === FAN.pk));
  check('the player keeps playing through the renders', await waitFor(playing, 3000));
  if (process.env.SHOT) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(400); await page.screenshot({ path: process.env.SHOT }); }

  console.log('\n[ways out]');
  await page.evaluate(() => document.querySelector('.live-page .chat-avatar.mini.live-ring').click());
  check('on the stream, the host\'s ringed face opens their profile (not the same stream again)', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-banner'), 8000));
  await page.goBack();
  await waitFor(() => !!document.querySelector('.live-page'), 5000);
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  check('Feed in the nav leaves the stream on the first tap', await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000));
  await page.goBack();
  await waitFor(() => !!document.querySelector('.live-page'), 5000);

  console.log('\n[leaving]');
  await page.goBack();
  check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
  check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
  await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
  await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000);
  await page.evaluate(() => document.querySelector('.notes-feed .live-card').click());
  check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000));
  await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); });
  check('the host\'s profile lists their past streams', await waitFor(() => [...document.querySelectorAll('.live-past-row .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000));
  await page.evaluate(() => [...document.querySelectorAll('.live-past-row .live-tile')].find((t) => /Zelda on Rumble/.test(t.innerText)).click());
  check('a recording kept as a web page: ended, and a link to watch it there, no player', await waitFor(() => !!document.querySelector('.live-page a.live-rec-link[href="https://rumble.com/v7gt2va-zelda.html"]'), 5000)
    && await page.evaluate(() => /This stream has ended/.test(document.body.innerText) && !document.querySelector('.live-page video.live-video')));
  await page.goBack();
  await waitFor(() => !document.querySelector('.live-page'), 5000);
  check('the host\'s profile says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000));
  check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
  await page.evaluate(() => [...document.querySelectorAll('.live-past-row .live-tile')].find((t) => /Yesterday on coinos/.test(t.innerText)).click());
  check('a past stream opens on its recording, playing', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000)
    && await page.evaluate(() => !document.body.innerText.includes('This stream has ended')));
  check('no page errors', !page.errs.length, page.errs.join(' | '));
  await page.close();

  console.log('\n[a browser without HLS of its own, opening a link]');
  const ff = await newPage(true);
  const f = helpers(ff);
  await ff.goto(`http://localhost:${PORT}/${NADDR}`, { waitUntil: 'domcontentloaded' });
  check('a /naddr1… link opens the stream, no wallet needed', await f.waitFor(() => !!document.querySelector('.live-page'), 15000) && await f.waitText('Building coinos live', 3000));
  check('hls.js plays it', await f.waitFor(playing, 25000), JSON.stringify(await ff.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, src: v.currentSrc.slice(0, 30), err: v.error && v.error.code }; })));
  check('...through Media Source', await ff.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
  check('a visitor reads the chat', await f.waitText('hello from the chat', 8000));
  await ff.evaluate((e) => window.__push(e), ended);
  check('the stream ending while watched says so, the player gone', await f.waitText('This stream has ended', 5000) && await ff.evaluate(() => !document.querySelector('video.live-video')));
  check('no page errors', !ff.errs.length, ff.errs.join(' | '));
} catch (e) { console.error(e); ok = false; } finally {
  await browser.close(); server.stop(true);
  rmSync(dir, { recursive: true, force: true });
}
console.log(ok ? '\n✅ live streams: found, watched, chatted in' : '\n❌ failed');
process.exit(ok ? 0 : 1);