aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/call-test.js28
-rw-r--r--tools/feed-media-views-test.js12
-rw-r--r--tools/feed-video-pager-test.js3
-rw-r--r--tools/go-live-test.js76
-rw-r--r--tools/grid-early-player-test.js134
-rw-r--r--tools/live-stream-test.js48
-rw-r--r--tools/media-moments-feed-test.js171
-rw-r--r--tools/media-timeline-test.js80
-rw-r--r--tools/notif-faces-test.js6
-rw-r--r--tools/pager-eager-test.js20
-rw-r--r--tools/pager-prime-test.js131
-rw-r--r--tools/pager-seek-hide-test.js26
-rw-r--r--tools/relay-link-test.js66
-rw-r--r--tools/zap-signer-test.js43
14 files changed, 816 insertions, 28 deletions
diff --git a/tools/call-test.js b/tools/call-test.js
index 15b3d98..d5c3909 100644
--- a/tools/call-test.js
+++ b/tools/call-test.js
@@ -53,7 +53,8 @@ const server = Bun.serve({ port: PORT,
},
});
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'] }); // a screen to share, chosen without asking
// our DM relays lead to the test's relay
const toLocal = (port) => {
const Real = window.WebSocket;
@@ -166,6 +167,31 @@ try {
await B.page.evaluate(() => document.querySelector('.call-add-video').click());
check('Bob turns his on: Alice sees him, both still talking', await waitFor(A.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000)
&& (await media(A.page)).audio && (await media(B.page)).audio && await live(A.page), JSON.stringify(await media(A.page)));
+
+ console.log('\n[sharing a screen]');
+ const dims = (p) => p.evaluate(() => { const v = document.querySelector('video.call-remote'); return v ? v.videoWidth + 'x' + v.videoHeight : ''; });
+ const camDims = await dims(B.page);
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('Alice shares her screen: her preview shows it, the button says Stop sharing', await waitFor(A.page, () => !!document.querySelector('video.call-local.sharing') && !!document.querySelector('.call-share.on[aria-label="Stop sharing"]'), 10000));
+ check('...Bob sees the screen in place of her camera', await waitFor(B.page, (d) => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && v.videoWidth + 'x' + v.videoHeight !== d; }, 15000, camDims), camDims + ' → ' + await dims(B.page));
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ const camOf = (p) => p.evaluate(() => { const v = document.querySelector('video.call-local'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; });
+ check('stopping brings her camera back: it\'s her camera she sends, Bob still sees her', await waitFor(A.page, () => { const v = document.querySelector('video.call-local:not(.sharing)'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000)
+ && await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000), await camOf(A.page) + ' | Bob ' + await dims(B.page));
+ await A.page.evaluate(() => document.querySelector('.call-hang').click());
+ await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000);
+ await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
+ await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
+ await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000);
+ await B.page.evaluate(() => document.querySelector('.call-accept').click());
+ await live(A.page); await live(B.page);
+ await waitFor(A.page, () => !!document.querySelector('.call-share'), 3000);
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('in a voice call, a shared screen shows on Bob\'s side', await waitFor(B.page, () => !!document.querySelector('.call-overlay.has-video') && document.querySelector('video.call-remote').videoWidth > 0, 15000));
+ await A.page.evaluate(() => document.querySelector('.call-share').click());
+ check('...and goes when she stops (no camera to come back), the call carrying on', await waitFor(B.page, () => !document.querySelector('.call-overlay.has-video'), 15000)
+ && await live(B.page) && (await media(B.page)).audio);
+ check('...and her camera never came on', await A.page.evaluate(() => !document.querySelector('video.call-local')));
await A.page.evaluate(() => document.querySelector('.call-hang').click());
await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000);
await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index 58e942b..87bf3cf 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -126,8 +126,8 @@ try {
await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
console.log('\n[the switch]');
- check('Notes / Conversations / Images / Videos / Articles sit under the chips, Notes on', await page.evaluate(() =>
- [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Articles'));
+ check('Notes / Conversations / Images / Videos / Live / Articles sit under the chips, Notes on', await page.evaluate(() =>
+ [...document.querySelectorAll('.media-mode')].map((b) => b.textContent + (b.classList.contains('on') ? '*' : '')).join(',') === 'Notes*,Conversations,Images,Videos,Live,Articles'));
// five chips are wider than a phone: the strip scrolls sideways, the page does not get wider
check('…the strip scrolls sideways rather than widening the page', await page.evaluate(() => {
const s = document.querySelector('.media-modes');
@@ -193,9 +193,9 @@ try {
check('...another plays it on, the details down', st.playing[0] && !st.show, JSON.stringify(st));
await sleep(1000);
let ss = await srcs();
- // (the next two get a head start fetched into the cache — pager-eager-test — but no player of their own)
- check('only the clip on screen has its file; the rest nothing yet',
- ss[0].src && ss[0].pre === 'auto' && !ss[1].src && !ss[2].src, JSON.stringify(ss));
+ // (the next gets a warm player, read to its first frame — pager-eager-test; the one after, its opening in the cache only)
+ check('the clip on screen has its file, the next a warm player, the one after nothing yet',
+ ss[0].src && ss[0].pre === 'auto' && ss[1].src && ss[1].pre === 'metadata' && !ss[2].src, JSON.stringify(ss));
await waitFor(() => Object.keys(JSON.parse((window.coinosCache || localStorage).getItem('coinos-video-info-v2') || '[]')).length >= 3, 6000);
await sleep(1500);
const vi = await info();
@@ -208,7 +208,7 @@ try {
st = await pagerState();
check('↓ flips to the next, which plays while the first stops', st.i === 1 && st.src === 'b.mp4' && st.playing[1] && !st.playing[0], JSON.stringify(st));
ss = await srcs();
- check('...and the window moves with it: the first lets go, the big one has no player yet', !ss[0].src && ss[1].pre === 'auto' && !ss[2].src, JSON.stringify(ss));
+ check('...and the window moves with it: the first keeps its player (for the swipe back), the big one next is warm', ss[0].src && ss[1].pre === 'auto' && ss[2].src && ss[2].pre === 'metadata', JSON.stringify(ss));
await page.keyboard.press('ArrowDown'); await sleep(1200);
st = await pagerState();
check('flipping is asking to watch: the 50 MB clip plays when it comes up', st.src === 'c.mp4' && st.playing[2] && await page.evaluate(() => !document.querySelectorAll('.mp-slide')[2].querySelector('.vid-heavy')), JSON.stringify(st));
diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js
index add9208..cccde41 100644
--- a/tools/feed-video-pager-test.js
+++ b/tools/feed-video-pager-test.js
@@ -120,6 +120,9 @@ try {
check('the seek bar sits clear of the bottom edge', geo.bottom >= 32, JSON.stringify(geo));
check('…and in from the sides', geo.left >= 16 && geo.right >= 16, JSON.stringify(geo));
if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.media-pager').classList.add('show')); await sleep(300); await page.screenshot({ path: process.env.SHOT }); await page.evaluate(() => document.querySelector('.media-pager').classList.remove('show')); }
+ // (out of sight while it plays: a tap near the foot brings it up)
+ { const r = await page.evaluate((cur) => { const b = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y - 30 }; }, '(' + CUR + ')'); await page.mouse.click(r.x, r.y); await sleep(300); }
+ check('a tap near the foot brings the seek bar up', await page.evaluate((cur) => getComputedStyle(eval(cur)().querySelector('.mp-seek')).opacity === '1', '(' + CUR + ')'));
const bar = await page.evaluate((cur) => { const r = eval(cur)().querySelector('.mp-seek').getBoundingClientRect(); return { x: r.x, y: r.y + r.height - 4, w: r.width }; }, '(' + CUR + ')');
await page.mouse.move(bar.x + bar.w * 0.1, bar.y);
await page.mouse.down();
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);
diff --git a/tools/grid-early-player-test.js b/tools/grid-early-player-test.js
new file mode 100644
index 0000000..e6dd540
--- /dev/null
+++ b/tools/grid-early-player-test.js
@@ -0,0 +1,134 @@
+// Opening a clip from the Videos grid: the clip hosts are preconnected,
+// and the player starts loading when the finger comes down on a tile —
+// the pager adopts that very element, so it's moving sooner after the tap.
+// A press that isn't followed by a tap (a scroll) lets its player go.
+// Run: bun tools/grid-early-player-test.js
+//
+// (adapted from pager-eager-test:)
+// Flipping through clips in the pager is asking to watch them: a clip plays
+// at once whatever it costs — even one over the size budget, which in the
+// feed waits under a tap-to-play cover — and the next two get a head start:
+// their opening fetched into the cache, no more of a big file than that, and
+// the player takes it from there. How much: a couple of seconds' worth of
+// the clip on a link well ahead of its bitrate (the second clip ahead, half
+// that); on a link slower than the clip, more — to the 4 MB cap. With Data
+// Saver on, the pager is careful again: such a clip waits for a tap and
+// nothing loads ahead.
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync, statSync } from 'node:fs';
+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; };
+
+// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
+const BIG = '/tmp/coinos-prime-small.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const html = await buildHtml({ minify: true, pwa: false });
+// every request for a clip's bytes: who asked (the player, or a fetch), the
+// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
+let log = [];
+let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
+ const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
+ log.push(entry);
+ let at = start;
+ const t0 = Date.now();
+ const body = new ReadableStream({ async pull(ctl) {
+ if (at > end) { ctl.close(); return; }
+ if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
+ }
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin.replace('localhost', '127.0.0.1') + '/' + n + '.webm'; // another origin than the page's: one to preconnect to
+const seeded = [
+ // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
+];
+const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
+const kb = (n) => Math.round(n / 1024) + ' KB';
+const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
+const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
+const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+async function run() {
+ log = [];
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.newPage();
+ await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+ const vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
+ try {
+ await page.setViewport({ width: 390, height: 844 });
+ pace = 12_500_000;
+ await page.goto(server.url.href, { 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', 20000);
+ 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: 'Prime Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await page.waitForSelector('.media-mode[data-mode="videos"]', { timeout: 20000 });
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ check('the Videos grid shows the clips', await waitFor(() => document.querySelectorAll('.media-tile.video').length >= 3, null, 15000));
+ check('…their host is connected to ahead', await page.evaluate((o) => [...document.querySelectorAll('link[rel=preconnect]')].some((l) => l.href.startsWith(o)), server.url.origin.replace('localhost', '127.0.0.1')));
+ // a press with no tap after it (the start of a scroll): its player is let go
+ await page.evaluate(() => document.querySelectorAll('.media-tile.video')[1].dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
+ await sleep(600);
+ const pressed = await page.evaluate(() => { const v = document.querySelector('body > video[data-early]'); return v && { src: v.getAttribute('src'), rs: v.readyState }; });
+ check('a press starts the clip loading before any tap: its player has its picture', pressed && /c1\.webm$/.test(pressed.src) && pressed.rs >= 2, JSON.stringify(pressed));
+ await sleep(2500);
+ check('…and let go when no tap follows', await page.evaluate(() => !document.querySelector('body > video[data-early]')));
+ // a press, then the tap: the pager plays the very player the press made
+ await page.evaluate(() => { const t = document.querySelectorAll('.media-tile.video')[0]; t.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); setTimeout(() => t.click(), 120); });
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
+ check('…on the player the press started, adopted', await waitFor(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && v.dataset.early === '1' && !v.paused && v.currentTime > 0; }, null, 8000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return v && { early: v.dataset.early, paused: v.paused, t: v.currentTime }; })));
+ check('…and only one player was ever made for it', await page.evaluate(() => document.querySelectorAll('.media-pager video.mp-media[data-early]').length) === 1);
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(); } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a clip opened from the grid starts loading at the press' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
index 0deb48c..ef76043 100644
--- a/tools/live-stream-test.js
+++ b/tools/live-stream-test.js
@@ -55,9 +55,17 @@ const pastPage = finalizeEvent({ kind: 30311, created_at: now - 50000, content:
['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);
+// who's watching (NIP-53 presence): the fan, just now; someone two hours ago is gone
+const here = finalizeEvent({ kind: 10312, created_at: now - 60, content: '', tags: [['a', A, '', 'root']] }, FAN.sk);
+const longGone = finalizeEvent({ kind: 10312, created_at: now - 7200, content: '', tags: [['a', A, '', 'root']] }, STALE.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];
+// and months of older ones, past the first ask's month: the grid pages back through them
+const OLD = Array.from({ length: 130 }, (_, i) => finalizeEvent({ kind: 30311, created_at: now - 31 * 86400 - i * 3600, content: '', tags: [
+ ['d', 'old-' + i], ['title', 'Old stream ' + i], ['status', 'ended'], ['streaming', STREAM], ['recording', STREAM],
+ ['ends', String(now - 31 * 86400 - i * 3600)], ['p', HOST.pk, '', 'Host'],
+] }, SERVICE.sk));
+const FIXTURES = [live, stale, past, pastPage, chat, zap, reaction, messageZap, here, longGone, ...OLD];
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)
@@ -81,7 +89,7 @@ const relayStub = (fx) => {
// 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)
+ && (!f.since || e.created_at >= f.since) && (!f.until || e.created_at <= f.until)
&& 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) {
@@ -92,8 +100,9 @@ const relayStub = (fx) => {
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)));
+ subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, until: undefined, limit: undefined }))]);
+ // each filter its newest `limit`, as a relay answers
+ const hits = [...new Set(m.slice(2).flatMap((f) => [...fx, ...window.__published].filter((e) => match(f, e)).sort((x, y) => y.created_at - x.created_at).slice(0, f.limit || Infinity)))];
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); }
@@ -168,6 +177,20 @@ try {
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)));
+ console.log('\n[past streams scroll on]');
+ const pastCount = () => page.evaluate(() => document.querySelectorAll('.live-past .live-tile').length);
+ const first = await pastCount();
+ check('a page of them at first, not all', first > 0 && first <= 30, first + ' tiles');
+ let last = first;
+ for (let i = 0; i < 120 && !(await page.evaluate(() => document.body.innerText.includes('Old stream 129'))); i++) { // (this relay never says EOSE: each page waits out its 5 s)
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(250);
+ last = await pastCount();
+ }
+ check('scrolling down brings older ones, from the relays, back to the oldest', await page.evaluate(() => document.body.innerText.includes('Old stream 129')), last + ' tiles');
+ check('...asked for page by page (until the oldest so far)', await page.evaluate(() => window.__reqs.filter((r) => r.slice(1).some((f) => f.kinds && f.kinds.includes(30311) && f.until)).length >= 2));
+ check('...all of them there, once each', last === 132 && await page.evaluate(() => new Set([...document.querySelectorAll('.live-past .live-tile')].map((t) => t.dataset.key)).size === 132), last + ' tiles');
+ await page.evaluate(() => window.scrollTo(0, 0));
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));
@@ -183,8 +206,14 @@ try {
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) }; })));
- check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM));
+ // 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));
+ const who = () => page.evaluate(() => { const x = document.querySelector('.live-page .live-watching'); return x && { text: x.textContent, title: x.getAttribute('title') || '' }; });
+ check('"watching" names who, on hover: the fan and this viewer, not someone long gone', await waitFor(() => (document.querySelector('.live-page .live-watching')?.getAttribute('title') || '').split(', ').length === 2, 8000), JSON.stringify(await who()));
+ check('...this viewer said they\'re here (a 10312 for the stream)', await page.evaluate((a) => window.__published.some((e) => e.kind === 10312 && e.tags.some((t) => t[0] === 'a' && t[1] === a)), A));
+ await page.evaluate(() => document.querySelector('.live-page .live-watching').click());
+ check('...a tap shows their faces', await waitFor(() => document.querySelectorAll('.live-page .live-who .live-who-p').length === 2, 3000));
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'));
@@ -232,21 +261,24 @@ try {
console.log('\n[leaving]');
await page.goBack();
check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
+ check('...and takes back this viewer\'s presence (an empty 10312)', await waitFor(() => window.__published.some((e) => e.kind === 10312 && !e.tags.some((t) => t[0] === 'a')), 3000));
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('the host\'s profile has a Streams tab', await waitFor(() => !!document.querySelector('.prof-tabs [data-tab="streams"]'), 10000) && !(await page.$('.live-past')));
+ await page.evaluate(() => document.querySelector('.prof-tabs [data-tab="streams"]').click());
+ check('...listing their past streams', await waitFor(() => [...document.querySelectorAll('.live-past .live-tile')].some((t) => /Yesterday on coinos/.test(t.innerText)), 10000));
+ await page.evaluate(() => [...document.querySelectorAll('.live-past .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());
+ await page.evaluate(() => [...document.querySelectorAll('.live-past .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(' | '));
diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js
new file mode 100644
index 0000000..f2d2774
--- /dev/null
+++ b/tools/media-moments-feed-test.js
@@ -0,0 +1,171 @@
+// Full feed integration with local media and relay acknowledgements; no events leave the test.
+// Run: bun tools/media-moments-feed-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { mkdtempSync } 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 dir = mkdtempSync(tmpdir() + '/media-');
+const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
+for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// index last (no faststart), from a server that ignores ranges
+ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
+for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);
+
+ff(['-f', 'lavfi', '-i', 'sine=frequency=440:duration=12', `${dir}/audio.mp3`]);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ if (path === '/audio.mp3') {
+ const f = Bun.file(dir + '/audio.mp3'), m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'audio/mpeg', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); }
+ return new Response(f, { headers: { 'content-type': 'audio/mpeg' } });
+ }
+ if (path.startsWith('/v/')) {
+ const f = Bun.file(dir + path.slice(2));
+ const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m && path !== '/v/slow.mp4') {
+ const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1);
+ // c.mp4 stands in for a 50 MB file: its server says so
+ const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file)
+ return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } });
+ }
+ return new Response(f, { headers: { 'content-type': 'video/mp4' } });
+ }
+ if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const base = server.url.origin;
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const FAN = generateSecretKey();
+const now = Math.floor(Date.now() / 1000);
+const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK);
+const notes = [
+ post(0, 'first clip, look at this ' + base + '/v/a.mp4'),
+ post(1, 'a plain post with no media at all'),
+ post(2, 'sunset over the bay ' + base + '/i/p1.jpg'),
+ post(3, base + '/v/b.mp4'),
+ post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'),
+ // the post says this one is 50 MB (it isn't; the post is believed)
+ post(5, 'a huge clip ' + base + '/v/c.mp4', [['imeta', 'url ' + base + '/v/c.mp4', 'm video/mp4', 'size 50000000', 'dim 360x640']]),
+ post(6, 'index last ' + base + '/v/slow.mp4'),
+];
+notes.unshift(post(9, 'Listen ' + base + '/audio.mp3', [['imeta', 'url ' + base + '/audio.mp3', 'waveform 1 5 2 8 4 10']]));
+const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN);
+
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((like) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const f = m[2] || {};
+ const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : [];
+ setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return send(data);
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, LIKE);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
+const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m);
+const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); };
+// which slide fills the screen, and what's playing
+const pagerState = () => page.evaluate(() => {
+ const p = document.querySelector('.media-pager');
+ if (!p) return null;
+ const sc = p.querySelector('.mp-scroll');
+ const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
+ const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
+ const vids = slides.map((s) => s.querySelector('video'));
+ return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
+ playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'),
+ meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity };
+});
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(base, { 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', 20000);
+ const key = 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: 'Shutterbug', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [key, PK, notes]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });
+
+
+ await page.waitForSelector('.note-audio audio');
+ await page.evaluate(() => document.querySelector('.note-audio audio').play());
+ await page.waitForSelector('.note-audio .media-timeline');
+ await page.waitForFunction(() => document.querySelectorAll('.note-audio .media-wave i').length === 6);
+ check('published audio waveform renders in the actual feed', true);
+ await page.evaluate(() => { const a = document.querySelector('.note-audio audio'); a.pause(); a.currentTime = 5; });
+ await sleep(100);
+ await page.evaluate(() => document.querySelectorAll('.note-audio .media-moment-actions button')[1].click());
+ await page.waitForSelector('.msg-sheet');
+ await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await sleep(400);
+ const reaction = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'media')));
+ check('reaction is signed with the selected file and position', reaction?.tags.some(t => t[0] === 'media' && t[2] === '5'));
+ check('reaction marker appears while paused', await page.evaluate(() => !!document.querySelector('.note-audio .media-mark')));
+ await page.screenshot({ path: '/tmp/media-timeline-playing.png' });
+ await page.evaluate(() => document.querySelector('.note-audio .media-moment-actions button').click());
+ await page.waitForSelector('.thread-reply-input');
+ check('timestamp is visible in the reply composer', (await page.evaluate(() => document.body.innerText)).includes('Comment at 0:05'));
+ await page.evaluate(() => { const input = document.querySelector('.thread-reply-input'); input.value = 'Great moment'; input.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.click('.thread-reply-send');
+ await sleep(500);
+ const comment = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 1 && e.content.includes('Great moment')));
+ check('comment includes a readable timestamp and signed media context', comment?.content === '[0:05] Great moment' && comment.tags.some(t => t[0] === 'media' && t[2] === '5'));
+ check('comment shows a jump-to-time link', await page.evaluate(() => !!document.querySelector('.media-time-link')));
+ await page.evaluate(() => document.querySelector('.media-time-link').click());
+ await page.waitForFunction(() => document.querySelector('.note-audio audio')?.currentTime >= 5);
+ check('the comment link starts playback at its timestamp', true);
+ await click('back'); await sleep(250);
+ await page.evaluate(() => document.querySelector('video.note-video').play());
+ await page.waitForSelector('.note-video-box .media-moment-actions');
+ await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.currentTime = 1; });
+ await sleep(150);
+ await page.evaluate(() => document.querySelectorAll('.note-video-box .media-moment-actions button')[1].click());
+ await page.waitForSelector('.msg-sheet');
+ await page.evaluate(() => document.querySelector('.msg-sheet button').click());
+ await sleep(300);
+ check('video reaction carries the video URL and selected second', await page.evaluate(() => window.__relayGuard.events.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'media' && t[1].endsWith('.mp4') && t[2] === '1'))));
+ check('no browser exceptions', errs.length === 0, errs.join(' | '));
+ await page.screenshot({ path: '/tmp/media-timeline-feed.png' });
+} finally { await browser.close(); server.stop(); }
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-timeline-test.js b/tools/media-timeline-test.js
new file mode 100644
index 0000000..c8f1fd9
--- /dev/null
+++ b/tools/media-timeline-test.js
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import { mediaMoment, momentTags, readMoment, waveformFromTags, waveformPeaks } from '../src/media-timeline.js';
+import { buildZapRequest } from '../src/lnurl.js';
+import puppeteer from './puppeteer.js';
+const url = 'https://example.com/song.mp3';
+const moment = mediaMoment(url, 65.9);
+assert.deepEqual(moment, { url, seconds: 65 });
+for (const value of [-1, Infinity, NaN]) assert.equal(mediaMoment(url, value), null);
+assert.equal(mediaMoment('javascript:alert(1)', 0), null);
+assert.deepEqual(readMoment({ tags: momentTags(moment) }), moment);
+assert.equal(readMoment({ tags: [['media', url, '']] }), null);
+const request = buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', moment, signFn: e => e });
+assert.equal(request.tags.find(t => t[0] === 'e')[1], 'e');
+assert.deepEqual(readMoment({ kind: 9735, tags: [['description', JSON.stringify(request)]] }), moment);
+assert.equal(readMoment(buildZapRequest({ amountMsat: 21000, relays: [], recipientPk: 'p', eventId: 'e', signFn: e => e })), null);
+assert.deepEqual(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform 1 5 2']] }, url), [1, 5, 2]);
+assert.equal(waveformFromTags({ tags: [['imeta', 'url ' + url, 'waveform -1 2']] }, url), null);
+assert.deepEqual(waveformPeaks({ numberOfChannels: 2, getChannelData: n => n ? [0, 0, .8, 0] : [.2, -.5, .1, 0] }, 2), [.5, .8]);
+const wav = new Uint8Array(44 + 16000);
+const dv = new DataView(wav.buffer);
+const word = (at, text) => wav.set(new TextEncoder().encode(text), at);
+word(0, 'RIFF'); dv.setUint32(4, wav.length - 8, true); word(8, 'WAVE'); word(12, 'fmt ');
+dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true); dv.setUint32(24, 8000, true); dv.setUint32(28, 16000, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); word(36, 'data'); dv.setUint32(40, 16000, true);
+for (let i = 0; i < 8000; i++) dv.setInt16(44 + i * 2, Math.sin(i * .3) * 20000, true);
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/wave.wav') return new Response(wav, { headers: { 'content-type': 'audio/wav' } });
+ if (path === '/large.wav') return new Response('', { headers: { 'content-length': String(21 * 1024 * 1024) } });
+ if (path === '/module.js') return new Response(Bun.file('src/media-timeline.js'), { headers: { 'content-type': 'text/javascript' } });
+ if (path === '/style.css') return new Response(Bun.file('src/style.css'));
+ return new Response('<!doctype html><link rel="stylesheet" href="/style.css"><body>', { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.goto(server.url.href);
+ const decoded = await page.evaluate(async () => {
+ const { loadWaveform } = await import('/module.js');
+ const peaks = await loadWaveform('/wave.wav');
+ return { count: peaks?.length, nonzero: peaks?.every(x => x > .5), fallback: await loadWaveform('/large.wav') };
+ });
+ assert.equal(decoded.count, 96); assert.equal(decoded.nonzero, true); assert.equal(decoded.fallback, null);
+ const result = await page.evaluate(async () => {
+ const { mediaTimeline } = await import('/module.js');
+ const h = (tag, attrs = {}, ...children) => {
+ const el = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) { if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (v === true) el.setAttribute(k, ''); else if (v !== false) el.setAttribute(k, v); }
+ el.append(...children); return el;
+ };
+ const player = h('audio');
+ Object.defineProperty(player, 'duration', { value: 120 });
+ let at = 0;
+ Object.defineProperty(player, 'currentTime', { get: () => at, set: x => { at = x; } });
+ const url = 'https://example.com/song.mp3';
+ const ev = { id: 'post', tags: [['imeta', 'url ' + url, 'waveform 1 10 4 8']] };
+ const calls = [], items = [{ id: 'a', moment: { seconds: 30 }, icon: '♡', text: 'Nice!' }];
+ const box = mediaTimeline(player, url, { h, event: () => ev, moments: () => items, action: (...a) => calls.push(a.slice(0, 3)), label: x => ({ mediaAtTime: 'at this time' }[x] || x) });
+ document.body.append(player, box);
+ player.dispatchEvent(new Event('timeupdate'));
+ const wave = box.querySelectorAll('.media-wave i').length;
+ const range = box.querySelector('input'); range.value = 500; range.dispatchEvent(new Event('input'));
+ const seek = at;
+ for (const b of box.querySelectorAll('.media-moment-actions button')) b.click();
+ box.querySelector('.media-mark').click();
+ const marker = at;
+ items.push({ id: 'b', moment: { seconds: 70 }, icon: '⚡', text: '' });
+ player.dispatchEvent(new Event('mediaactivity'));
+ window.fixture = box;
+ return { wave, seek, marker, calls, markers: box.querySelectorAll('.media-mark').length, aria: range.getAttribute('aria-valuetext') };
+ });
+ assert.equal(result.wave, 4);
+ assert.equal(result.seek, 60);
+ assert.equal(result.marker, 30);
+ assert.equal(result.markers, 2);
+ assert.deepEqual(result.calls.map(c => [c[0], c[1].id, c[2].seconds]), [['comment', 'post', 60], ['reaction', 'post', 60], ['zap', 'post', 60]]);
+ await page.setViewport({ width: 360, height: 740 });
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
+ await page.screenshot({ path: '/tmp/media-timeline-mobile.png' });
+ console.log('✓ Timestamp validation, Lightning receipt round-trip, untimed zap isolation, waveform peaks, browser seeking, all three actions, marker updates and mobile layout');
+} finally { await browser.close(); server.stop(); }
diff --git a/tools/notif-faces-test.js b/tools/notif-faces-test.js
index b45b120..a1c1c8f 100644
--- a/tools/notif-faces-test.js
+++ b/tools/notif-faces-test.js
@@ -73,6 +73,8 @@ try {
const ME = getPublicKey(privateKeyFromSeedWords(mn));
// a cached notifications list: five people reacted to my post
const items = ACTORS.map((a, i) => ({ id: 'r'.repeat(63) + i, what: 'react', actor: a.pk, target: 't'.repeat(64), hint: [], emoji: '❤️', ts: now - 60 * (i + 1) }));
+ // and one zapped my profile: no post to open
+ items.unshift({ id: 'z'.repeat(64), what: 'zap', actor: ACTORS[1].pk, target: null, hint: [], sats: 421, text: 'thanks!', ts: now - 30 });
await page.evaluate(([k, me, items]) => {
const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
st.notifs = items; st.notifsPk = me; st.notifsV = 2;
@@ -100,6 +102,10 @@ try {
await sleep(150); // the first paint, not a settled one
const faces = await page.evaluate(() => [...document.querySelectorAll('.notes-feed .chat-avatar')].map((a) => a.classList.contains('ava-img') || (a.style.backgroundImage || '').includes('url')));
check('every row paints with a face on the first frame', faces.length >= 5 && faces.every(Boolean), JSON.stringify(faces));
+ // a zap on my profile (no post): the tap opens the zapper's profile — it did nothing
+ await page.evaluate(() => [...document.querySelectorAll('.alert-row')].find((r) => r.textContent.includes('thanks!')).querySelector('.grow').click());
+ await sleep(500);
+ check('tapping a profile zap opens the zapper\'s profile', await page.evaluate((pk) => !document.querySelector('.alert-row') && /npub1|nprofile1/.test(location.pathname) && document.body.innerText.includes('Actor 1'), ACTORS[1].pk), await page.evaluate(() => location.pathname));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ notifications open with their faces on' : '\n❌ failed');
diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js
index 15a5076..3b5c5c3 100644
--- a/tools/pager-eager-test.js
+++ b/tools/pager-eager-test.js
@@ -88,7 +88,7 @@ async function run(saveData, slow = false) {
const feedRow = (w) => page.evaluate((x) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(x)); r.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = r.querySelector('video.note-video').getBoundingClientRect(); return { cover: !!r.querySelector('.vid-heavy'), x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; }, w);
try {
await page.setViewport({ width: 390, height: 844 });
- pace = slow ? 375000 : 0;
+ pace = slow ? 375000 : 12_500_000; // 3 Mbps, or 100 (a loopback's speed is no link: Chrome reads a warm player's whole file before it can stop)
await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
@@ -123,14 +123,17 @@ async function run(saveData, slow = false) {
const c1 = await slide('c1'), c2 = await slide('c2'), c3 = await slide('c3');
let head1 = 0;
if (!saveData) {
- check('the next clips wait on their pictures, no cover, no player yet', c1 && !c1.src && !c1.cover && c2 && !c2.src, JSON.stringify([c1, c2]));
+ check('the next clip has a warm player: its file, read to its first frame and paused', c1 && c1.src && c1.preload === 'metadata' && !c1.playing && !c1.cover && await page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); return s.querySelector('video.mp-media').readyState >= 2; }), JSON.stringify(c1));
+ check('…the one after waits on its picture, no player yet', c2 && !c2.src && !c2.cover, JSON.stringify(c2));
const hs = heads('c1'), h2 = heads('c2');
head1 = hs.length ? hs[0].end + 1 : 0;
const head2 = h2.length ? h2[0].end + 1 : 0;
// before any measure of the link: two seconds' worth (the second clip ahead, half)
check('the next clip\'s opening is fetched ahead: two seconds of it', hs.length === 1 && hs[0].start === 0 && near(head1, 2 * RATE), `${kb(head1)} (two seconds: ${kb(2 * RATE)})`);
check('…and half that of the one after', h2.length === 1 && near(head2, RATE), `${kb(head2)}`);
- check('nothing more than that ahead of time', sent('c1') <= head1 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
+ // the warm player reads on from the cached opening to its first frame and a little past, then stops
+ check('ahead of time: the next clip\'s opening and a few MB more for its warm player, the one after its opening only', sent('c1') <= head1 + 4 * 1024 * 1024 && sent('c2') <= head2 && sent('c3') === 0, `c1 ${kb(sent('c1'))}, c2 ${kb(sent('c2'))}, c3 ${kb(sent('c3'))} of ${kb(SIZE)} each`);
+ check('…the warm player took the opening from the cache, not the host', !log.some((e) => e.clip === 'c1' && e.dest === 'video' && e.start === 0), log.filter((e) => e.clip === 'c1').map((e) => e.dest + ':' + e.start).join(' '));
} else {
check('nothing loads ahead of a clip over budget', c1 && !c1.src && sent('c1') === 0, JSON.stringify(c1) + ' ' + kb(sent('c1')));
}
@@ -139,14 +142,21 @@ async function run(saveData, slow = false) {
const t0 = Date.now();
if (!saveData) {
check('swiped to, the next clip plays at once, no cover', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s.querySelector('video.mp-media'); return !s.querySelector('.vid-heavy') && !v.paused && v.currentTime > 0; }, null, 5000) && (Date.now() - t0) < (slow ? 3000 : 1500), (Date.now() - t0) + ' ms');
- const after = log.slice(before).filter((e) => e.clip === 'c1' && e.dest === 'video');
- check('…its player starting where the head start left off (the opening came from the cache)', after.length >= 1 && after[0].start === head1, 'the player asked for ' + (after.map((e) => e.start + '-').join(', ') || 'nothing') + ', head start ' + head1);
+ await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 3000);
+ const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
+ check('…from its warm player, the clock says', st && st.warm && st.ms < 1000, JSON.stringify(st));
await sleep(1500);
const h3 = heads('c3'), head3 = h3.length ? h3[0].end + 1 : 0;
check('…the next one gets its head start', h3.length === 1);
// the link measured now: a fast one, still a couple of seconds' worth; one slower than the clip, the cap
if (!slow) check('…a fast link measured: still two seconds\' worth (half, as the second ahead)', near(head3, RATE), kb(head3));
else check('…the link measured slower than the clip: it grows to the cap (half, as the second ahead)', head3 === HEAD_MAX / 2, kb(head3));
+ // the clip just left keeps its player: the swipe back is warm too
+ check('the clip just left keeps its player', !!(await slide('c0'))?.src);
+ await page.keyboard.press('ArrowUp');
+ await waitFor(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).length >= 2, null, 5000);
+ const back = await page.evaluate(() => coinosPagerStarts().starts.filter((x) => /c0\.webm$/.test(x.url)).at(-1));
+ check('…so swiping back to it starts warm', back && back.warm && back.ms < 1000, JSON.stringify(back));
} else {
await sleep(3000);
const s = await slide('c1');
diff --git a/tools/pager-prime-test.js b/tools/pager-prime-test.js
new file mode 100644
index 0000000..7263ee1
--- /dev/null
+++ b/tools/pager-prime-test.js
@@ -0,0 +1,131 @@
+// A warm player's sound is started before the swipe lands (an Android
+// phone held a still picture ~250 ms on a player's first play with sound):
+// the next clip plays at a volume nobody hears until its picture moves,
+// and pauses there, a frame or two in — not seeked back. Clips over 25 MB
+// aren't (once played, a player reads the whole file): pager-eager-test.
+// Run: bun tools/pager-prime-test.js
+//
+// (adapted from pager-eager-test:)
+// Flipping through clips in the pager is asking to watch them: a clip plays
+// at once whatever it costs — even one over the size budget, which in the
+// feed waits under a tap-to-play cover — and the next two get a head start:
+// their opening fetched into the cache, no more of a big file than that, and
+// the player takes it from there. How much: a couple of seconds' worth of
+// the clip on a link well ahead of its bitrate (the second clip ahead, half
+// that); on a link slower than the clip, more — to the 4 MB cap. With Data
+// Saver on, the pager is careful again: such a clip waits for a tap and
+// nothing loads ahead.
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync, statSync } from 'node:fs';
+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; };
+
+// small clips with sound (a tone), ~2 MB each: under the 25 MB a warm player is primed up to
+const BIG = '/tmp/coinos-prime-small.webm';
+if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=s=180x320:r=15', '-f', 'lavfi', '-i', 'sine=f=440', '-t', '50', '-c:v', 'libvpx', '-b:v', '300k', '-c:a', 'libvorbis', BIG]);
+const SIZE = statSync(BIG).size;
+const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
+const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const html = await buildHtml({ minify: true, pwa: false });
+// every request for a clip's bytes: who asked (the player, or a fetch), the
+// range, and the bytes actually sent (pulled by the reader, in 64 KB pieces)
+let log = [];
+let pace = 0; // bytes a second the server sends a clip at (0: as fast as it can) — a slow link
+const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
+ const path = new URL(req.url).pathname;
+ const m = /^\/(c\d)\.webm$/.exec(path);
+ if (m) {
+ const headers = { 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
+ const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
+ const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
+ log.push(entry);
+ let at = start;
+ const t0 = Date.now();
+ const body = new ReadableStream({ async pull(ctl) {
+ if (at > end) { ctl.close(); return; }
+ if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
+ const n = Math.min(65536, end - at + 1);
+ ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
+ } }, { highWaterMark: 0 });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
+ }
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+const seeded = [
+ // each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
+];
+const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
+const kb = (n) => Math.round(n / 1024) + ' KB';
+const RATE = SIZE / 50, HEAD_MAX = 4 * 1024 * 1024;
+const heads = (clip) => log.filter((e) => e.clip === clip && e.dest === 'empty');
+const near = (a, b) => Math.abs(a - b) <= b * 0.02 + 1;
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+async function run() {
+ log = [];
+ const ctx = await browser.createBrowserContext();
+ const page = await ctx.newPage();
+ await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+ const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+ const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+ const vid = (n) => page.evaluate((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/' + n + '.webm')); const v = s && s.querySelector('video.mp-media'); return v ? { src: !!v.getAttribute('src'), paused: v.paused, t: v.currentTime, volume: v.volume, muted: v.muted } : null; }, n);
+ try {
+ await page.setViewport({ width: 390, height: 844 });
+ pace = 12_500_000;
+ await page.goto(server.url.href, { 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', 20000);
+ 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: 'Prime Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
+ const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; });
+ await sleep(800);
+ await page.mouse.click(r.x, r.y);
+ check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
+ check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1')));
+ const c1 = await vid('c1');
+ check('...its volume back to full, and quiet until it\'s on screen', c1.volume === 1 && c1.muted, JSON.stringify(c1));
+ await page.keyboard.press('ArrowDown');
+ await waitFor(() => coinosPagerStarts().starts.some((x) => /c1\.webm$/.test(x.url)), null, 5000);
+ const st = await page.evaluate(() => coinosPagerStarts().starts.find((x) => /c1\.webm$/.test(x.url)));
+ check('swiped to, it plays with sound, primed', st && st.primed && st.warm && st.ms < 1000 && await page.evaluate(() => { const v = document.querySelector('.mp-cur video.mp-media'); return !v.muted && v.volume === 1 && !v.paused; }), JSON.stringify(st));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ } finally { await ctx.close(); }
+}
+try { await run(); } finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a warm player has its sound going before the swipe lands' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js
index 64ff7b5..d5d527b 100644
--- a/tools/pager-seek-hide-test.js
+++ b/tools/pager-seek-hide-test.js
@@ -2,7 +2,10 @@
// while the pager is in motion — a glide to the next clip, a finger
// dragging the slides, the snap back — no seek bar shows at all (it rode
// along with the slide, a white line sliding up the screen), and it fades
-// back in once the clip has landed. The position counter is the other way
+// back in once the clip has landed. And at rest it's out of sight too while
+// the clip plays (Adam, 2026-10-19): a tap near the foot of the screen brings
+// it up for a few seconds without pausing, and a paused clip shows it. The
+// position counter is the other way
// round: it shows while the pager moves and fades out once it has settled.
// Run: bun tools/pager-seek-hide-test.js
import puppeteer from './puppeteer.js';
@@ -88,10 +91,21 @@ try {
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
- console.log('\n[at rest: the clip on screen has its seek bar]');
+ console.log('\n[at rest: the seek bar comes when asked for]');
check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
- check('…and its seek bar shows', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && b.classList.contains('ready') && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars()));
+ await waitFor(() => !!document.querySelector('.mp-slide.mp-cur .mp-seek.ready'), null, 6000);
+ await sleep(400);
+ const curBar = () => page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return { opacity: getComputedStyle(b).opacity, pe: getComputedStyle(b).pointerEvents, playing: !v.paused }; });
+ check('…its seek bar out of sight (and out of reach) while it plays', (await curBar()).opacity === '0' && (await curBar()).pe === 'none', JSON.stringify(await curBar()));
+ await page.mouse.click(195, 844 - 70); // near the foot, where the bar sits
+ check('a tap near the foot brings it up, the clip playing on', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && (await curBar()).playing, JSON.stringify(await curBar()));
check('…no other', (await seenBars()).length === 1, (await seenBars()).join(' '));
+ check('…and it goes again a few seconds later', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 6000), JSON.stringify(await curBar()));
+ await page.mouse.click(195, 380);
+ check('a tap mid-screen pauses the clip, and the bar is up', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && !(await curBar()).playing, JSON.stringify(await curBar()));
+ await page.mouse.click(195, 380);
+ check('…another plays it on, the bar gone', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 2000) && (await curBar()).playing, JSON.stringify(await curBar()));
+ await page.mouse.click(195, 844 - 70); // up again, for the glide
check('the pager is not marked moving', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
check('the position counter has faded out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
@@ -106,11 +120,13 @@ try {
check('…and not one seek bar to be seen', between.every((f) => f.seen === 0), between.map((f) => f.seen).join(''));
const landedOn = await waitFor(() => { const s = document.querySelector('.mp-slide.mp-cur'); return !!s && s.getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000);
check('it lands on the next clip', landedOn);
- check('…whose seek bar fades back in', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '1'; }, null, 6000), JSON.stringify(await bars()));
+ await sleep(600);
+ check('…whose seek bar stays out of sight until asked for', await page.evaluate(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return !!b && getComputedStyle(b).opacity === '0'; }), JSON.stringify(await bars()));
check('…the one before, now off screen, shows none', (await bars()).filter((x) => x.clip === 'wide.webm').every((x) => !x.seen), JSON.stringify(await bars()));
check('the pager is not marked moving once landed', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
console.log('\n[a finger holding the slides off their mark: none either]');
+ await page.mouse.click(195, 844 - 70); await sleep(300); // the bar up first
await page.touchscreen.touchStart(195, 600);
await page.touchscreen.touchMove(195, 560); await sleep(30);
await page.touchscreen.touchMove(195, 500); await sleep(250);
@@ -119,7 +135,7 @@ try {
check('…and the position counter is back', (await counterOpacity()) === 1, String(await counterOpacity()));
await page.touchscreen.touchMove(195, 590); await sleep(30);
await page.touchscreen.touchEnd();
- check('let go, it snaps back and the bar returns', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && getComputedStyle(b).opacity === '1' && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars()));
+ check('let go, it snaps back', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); const el = document.querySelector('.media-pager'); return !!b && !el.classList.contains('moving') && document.querySelector('.mp-slide.mp-cur').getAttribute('data-k').endsWith('/tall.webm'); }, null, 4000), JSON.stringify(await bars()));
await sleep(500);
check('a moment after, the counter still shows', (await counterOpacity()) > 0.99, String(await counterOpacity()));
check('…then fades out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
diff --git a/tools/relay-link-test.js b/tools/relay-link-test.js
new file mode 100644
index 0000000..52984ad
--- /dev/null
+++ b/tools/relay-link-test.js
@@ -0,0 +1,66 @@
+// A relay named in a post (wss://…) is a link: tapping it opens the raw
+// firehose of that relay (/feed/all?r=…), read from that relay alone.
+// Run: bun tools/relay-link-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { buildHtml, buildVerifyWorker } from '../build.js';
+
+const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000);
+const note = finalizeEvent({ kind: 1, created_at: now, tags: [], content: 'I run a good relay to read directly:\n\nwss://theforest.nostr1.com.\n\nAnyone can read from it.' }, sk);
+const forest = finalizeEvent({ kind: 1, created_at: now - 5, tags: [], content: 'Hello from the forest relay, one of its own posts.' }, generateSecretKey());
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Relay Admin' }) }, sk);
+const html = await buildHtml({ minify: true, pwa: false });
+const worker = await buildVerifyWorker({ minify: true });
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/api/feed') return Response.json({ version: 1, generatedAt: Date.now(), popUntil: now - 100, notes: [note], profiles: [profile] });
+ if (path === '/verify-worker.js') return new Response(worker, { headers: { 'Content-Type': 'text/javascript' } });
+ if (path.startsWith('/punks')) return new Response(null, { status: 404 });
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setRequestInterception(true);
+ page.on('request', (r) => (r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()));
+ await page.evaluateOnNewDocument((forest) => {
+ window.asked = [];
+ class FakeSocket extends EventTarget {
+ static OPEN = 1; static CLOSED = 3;
+ readyState = 0;
+ constructor(url) { super(); this.url = url; setTimeout(() => { this.readyState = 1; this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); }
+ emit(m) { const e = new MessageEvent('message', { data: JSON.stringify(m) }); this.onmessage?.(e); this.dispatchEvent(e); }
+ send(data) {
+ const m = JSON.parse(data);
+ if (m[0] !== 'REQ') return;
+ window.asked.push({ url: this.url, filter: m[2] });
+ setTimeout(() => {
+ if (/theforest/.test(this.url) && m[2].kinds?.includes(1) && !m[2].ids && !m[2].authors) this.emit(['EVENT', m[1], forest]);
+ this.emit(['EOSE', m[1]]);
+ }, 0);
+ }
+ close() { this.readyState = 3; this.onclose?.({}); }
+ }
+ window.WebSocket = FakeSocket;
+ }, forest);
+ await page.goto(server.url.origin + '/feed');
+ await page.waitForFunction(() => document.body.innerText.includes('I run a good relay'), { timeout: 5000 });
+ const link = await page.$$eval('.notes-feed a', (as) => as.filter((a) => /theforest/.test(a.textContent)).map((a) => ({ text: a.textContent, href: a.getAttribute('href') })));
+ check('the relay in the post is a link', link.length === 1, JSON.stringify(link));
+ check('...without the full stop after it', link[0].text === 'wss://theforest.nostr1.com' && /theforest\.nostr1\.com\./.test(await page.evaluate(() => document.querySelector('.notes-feed').innerText)));
+ check('...to that relay\'s firehose', link[0].href === '/feed/all?r=' + encodeURIComponent('wss://theforest.nostr1.com'), link[0].href);
+ await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].find((a) => /theforest/.test(a.textContent)).click());
+ check('tapping it shows the relay\'s own posts', await page.waitForFunction(() => document.body.innerText.includes('Hello from the forest relay'), { timeout: 8000 }).then(() => true, () => false));
+ const forestAsks = await page.evaluate(() => window.asked.filter((a) => /theforest/.test(a.url) && a.filter.kinds?.includes(1) && !a.filter.authors && !a.filter.ids));
+ check('...asked of that relay, for everything on it', forestAsks.length > 0);
+ check('...and the address bar says so', decodeURIComponent(await page.evaluate(() => location.pathname + location.search)) === '/feed/all?r=wss://theforest.nostr1.com', await page.evaluate(() => location.href));
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ relay links open the relay');
+} finally {
+ await browser.close();
+ server.stop(true);
+}
diff --git a/tools/zap-signer-test.js b/tools/zap-signer-test.js
new file mode 100644
index 0000000..13f6008
--- /dev/null
+++ b/tools/zap-signer-test.js
@@ -0,0 +1,43 @@
+// A zap is signed as the person zapping: with a login identity (extension,
+// bunker, passkey…) the zap request's pubkey is that identity, not the
+// wallet's own key — a signed-in person's zaps showed in stream chats (and
+// streamers' overlays) under the wallet key's stranger npub. Without a
+// login, the wallet key signs as before.
+// Run: bun tools/zap-signer-test.js
+import assert from 'node:assert/strict';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { zapsFeature } from '../src/features/zaps.js';
+
+const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const walletSk = generateSecretKey(), loginSk = generateSecretKey(), host = getPublicKey(generateSecretKey());
+let asked = null;
+globalThis.fetch = async (url) => {
+ const u = new URL(url);
+ if (u.pathname.startsWith('/.well-known/lnurlp/')) return Response.json({ tag: 'payRequest', callback: 'https://ln.example/cb', minSendable: 1000, maxSendable: 1e9, allowsNostr: true, nostrPubkey: host });
+ if (u.pathname === '/cb') { asked = JSON.parse(u.searchParams.get('nostr')); return Response.json({ pr: 'lnbc210n1fake' }); }
+ return new Response('no', { status: 404 });
+};
+async function zapWith(login) {
+ asked = null;
+ let settled = null;
+ const ctx = {
+ h: () => null, ui: {}, render: () => {}, toast: (m) => console.log('toast:', m), fmtAmount: String, unitLabel: () => 'sats', parseAmount: Number, getUnit: () => 'sat', unitTag: () => '', blankSend: () => {},
+ wallet: { watchOnly: false, nostrRelays: () => ['wss://relay.example'], nostrProfile: async () => ({ lud16: '[email protected]' }), nostrSign: (e) => finalizeEvent(e, walletSk) },
+ hook(name, ...a) {
+ if (name === 'canLnPay' || name === 'arkReady') return true;
+ if (name === 'nostrLoginIdentity') return login;
+ if (name === 'arkPayInvoice') return Promise.resolve({});
+ if (name === 'zapSettled') { settled = a; return null; }
+ return null;
+ },
+ };
+ zapsFeature(ctx).lnZapNpub(host, null, 'e'.repeat(64), 21);
+ for (let i = 0; i < 50 && !settled; i++) await new Promise((r) => setTimeout(r, 20));
+ return { req: asked, settled };
+}
+const signedIn = await zapWith({ pubkey: getPublicKey(loginSk), signer: { signEvent: async (e) => finalizeEvent(e, loginSk) } });
+check('signed in: the zap goes through', signedIn.settled && signedIn.settled[1] === true, JSON.stringify(signedIn.settled));
+check('...and its request is signed by the login identity', signedIn.req && signedIn.req.kind === 9734 && signedIn.req.pubkey === getPublicKey(loginSk), signedIn.req && signedIn.req.pubkey);
+const plain = await zapWith(null);
+check('no login: the wallet\'s own key signs it', plain.req && plain.req.pubkey === getPublicKey(walletSk));
+console.log('\n✅ zaps are signed as the person zapping');