1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
|
// 1:1 calls (NIP-AC): two wallets, each in its own browser tab, a relay
// between them. The caller's š / š„ in their conversation rings the other
// over whatever page they're on; Answer connects them ā each hears and sees
// the other ā mute and camera-off reach the other side, hang-up ends it on
// both. A call can be declined, and a stranger's (neither followed nor
// written to) doesn't ring, but is in the conversation as a missed call. Everything on the wire is an ephemeral gift wrap.
// Run: bun tools/call-test.js (NO_BUILD=1 to reuse dist/)
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
import { getPublicKey } from 'nostr-tools/pure';
import { npubEncode } from 'nostr-tools/nip19';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? 'ā' : 'ā'} ${n}${d ? ' ā ' + d : ''}`); if (!c) ok = false; };
const PORT = 5305;
const SLOW = +(process.env.SLOW || 0);
if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
// a small relay: keeps what isn't ephemeral, sends each event to the subscriptions it matches
const stored = [], subs = new Set(), kindsSeen = [], wrapIds = new Set();
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
&& (!f.since || e.created_at >= f.since)
&& Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
const server = Bun.serve({ port: PORT,
fetch: async (req, srv) => {
const path = new URL(req.url).pathname;
if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
// two tabs on one machine: no TURN needed (SLOW=ms makes it, and the relay
// lookups, take as long as they do out there)
if (path === '/live/turn') { if (SLOW) await sleep(SLOW); return Response.json({ iceServers: [], ttl: 600 }); }
const f = Bun.file('dist' + path);
return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
},
websocket: {
open(ws) { ws.data = { subs: new Map() }; },
message(ws, raw) {
let m; try { m = JSON.parse(raw); } catch { return; }
if (m[0] === 'EVENT') {
const e = m[1]; kindsSeen.push(e.kind); if (e.kind === 21059) wrapIds.add(e.id);
ws.send(JSON.stringify(['OK', e.id, true, '']));
if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match({ ...f, since: undefined }, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
} else if (m[0] === 'REQ') {
if (SLOW && m.slice(2).some((f) => (f.kinds || []).includes(10050))) { setTimeout(() => ws.send(JSON.stringify(['EOSE', m[1]])), SLOW); return; }
const s = { ws, id: m[1], filters: m.slice(2) };
ws.data.subs.set(m[1], s); subs.add(s);
for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
ws.send(JSON.stringify(['EOSE', m[1]]));
} else if (m[0] === 'CLOSE') { const s = ws.data.subs.get(m[1]); if (s) subs.delete(s); }
},
close(ws) { for (const s of ws.data.subs.values()) subs.delete(s); },
},
});
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new',
args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required',
'--auto-select-desktop-capture-source=Entire screen'] }); // a screen to share, chosen without asking
// our DM relays lead to the test's relay
const toLocal = (port) => {
const Real = window.WebSocket;
window.WebSocket = function (url, p) { if (/relay\.coinos\.io|nos\.lol/.test(String(url))) url = `ws://localhost:${port}/relay`; return p ? new Real(url, p) : new Real(url); };
window.WebSocket.prototype = Real.prototype;
Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
};
async function wallet(name) {
const page = await (await browser.createBrowserContext()).newPage(); // a browser of its own: its own storage
await page.setViewport({ width: 390, height: 844 });
await page.evaluateOnNewDocument(toLocal, PORT);
page.errs = []; page.on('pageerror', (e) => page.errs.push(String(e)));
const click = (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x);
const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
await page.waitForSelector('.words .w .t');
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive');
return { page, mn, pk: getPublicKey(privateKeyFromSeedWords(mn)), base: cacheKeyFor(mn + '\n'), click, waitText, name };
}
const waitFor = async (page, fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; };
const follow = (w, pks, profiles) => w.page.evaluate(([k, pks, profiles]) => {
localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
}, [w.base, pks, profiles]);
// the call's pictures and sound actually moving: frames decoded, bytes in
const media = (page) => page.evaluate(async () => {
const v = document.querySelector('video.call-remote'), a = document.querySelector('audio.call-audio');
const s = a && a.srcObject;
return { video: !!v && v.videoWidth > 0 && !v.paused, audio: !!s && s.getAudioTracks().some((t) => t.readyState === 'live'), local: !!document.querySelector('video.call-local')?.videoWidth };
});
try {
const A = await wallet('alice'), B = await wallet('bob'), C = await wallet('carol');
const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() }, [C.pk]: { name: 'Carol Stranger', t: Date.now() } };
await follow(A, [B.pk], profiles); await follow(B, [A.pk], profiles); await follow(C, [B.pk], profiles);
for (const w of [A, B, C]) { await w.page.reload({ waitUntil: 'domcontentloaded' }); await w.waitText('receive'); }
const openDm = async (w, peer) => {
await w.page.goto(`http://localhost:${PORT}/` + npubEncode(peer), { waitUntil: 'domcontentloaded' });
await w.page.waitForSelector('.prof-actions', { timeout: 20000 });
await w.page.evaluate(() => { const b = [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)); b.click(); });
await w.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
};
console.log('[a video call]');
await openDm(A, B.pk);
check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2));
const w0 = wrapIds.size;
await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000));
check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000));
check('everything went as ephemeral gift wraps', kindsSeen.length > 0 && !kindsSeen.some((k) => k >= 25050 && k <= 25055) && kindsSeen.includes(21059));
if (process.env.RING_WAIT) await sleep(+process.env.RING_WAIT); // a person reaching for the phone
const tAccept = Date.now();
await B.page.evaluate(() => document.querySelector('.call-accept').click());
await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
const answerMs = Date.now() - tAccept;
check('answered: both connected, the clock running (' + answerMs + ' ms from Answer)', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
await sleep(1500);
const ma = await media(A.page), mb = await media(B.page);
check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb }));
// the routes ride in the offer and answer: a household's two devices
// calling each other mustn't flood the relays (a per-IP limit ate the answer)
check('...in a handful of messages, not one per route', wrapIds.size - w0 <= 6, (wrapIds.size - w0) + ' wraps');
await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click());
check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000));
await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click());
check('minimized: a bar across the top, the page usable under it', await waitFor(A.page, () => !!document.querySelector('.call-min-bar') && !document.querySelector('.call-bar'), 3000));
await A.page.evaluate(() => document.querySelector('.call-min-bar').click());
await waitFor(A.page, () => !!document.querySelector('.call-hang'), 3000);
await A.page.evaluate(() => document.querySelector('.call-hang').click());
check('hanging up ends it here', await waitFor(A.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 3000));
check('...and there', await waitFor(B.page, () => /call ended/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
check('...the cameras let go', await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000) && await waitFor(B.page, () => !document.querySelector('.call-overlay'), 6000));
const logs = (page) => page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText.replace(/\s+/g, ' ').trim()));
check('the call is in the caller\'s conversation: outgoing, how long', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing video call Ā· \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(A.page)));
await openDm(B, A.pk);
check('...and in the callee\'s: incoming', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Incoming video call Ā· \d+:\d\d/.test(r.innerText)), 5000), JSON.stringify(await logs(B.page)));
console.log('\n[declined]');
await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
check('a voice call rings', await waitFor(B.page, () => /voice call/i.test(document.querySelector('.call-overlay')?.innerText || ''), 15000));
await B.page.evaluate(() => document.querySelector('.call-decline').click());
check('declined: the caller is told', await waitFor(A.page, () => /declined/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000));
await waitFor(A.page, () => !document.querySelector('.call-overlay'), 6000);
check('...and it\'s logged so on both sides', await waitFor(A.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Outgoing voice call Ā· Declined/.test(r.innerText)), 5000)
&& await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Declined voice call/.test(r.innerText)), 5000), JSON.stringify([await logs(A.page), await logs(B.page)]));
await B.page.evaluate(() => document.querySelector('.chat-row.call-log .call-log-pill').click());
check('a tap on a logged call calls back', await waitFor(A.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000));
await A.page.evaluate(() => document.querySelector('.call-decline').click());
await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000);
console.log('\n[a voice call turned video]');
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());
const live = (p) => waitFor(p, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
check('a voice call connects, no cameras', await live(A.page) && await live(B.page) && !(await A.page.evaluate(() => !!document.querySelector('video.call-local'))));
check('...each side offered Turn on camera', await waitFor(A.page, () => !!document.querySelector('.call-add-video'), 3000) && await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000));
await A.page.evaluate(() => document.querySelector('.call-add-video').click());
check('Alice turns her camera on: she sees herself, and gets camera off and flip', await waitFor(A.page, () => !!document.querySelector('video.call-local')?.videoWidth && !document.querySelector('.call-add-video')
&& !!document.querySelector('.call-btn[aria-label="Switch camera"]'), 10000));
check('...Bob sees her', await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000), JSON.stringify(await media(B.page)));
check('...and can turn his on too', await waitFor(B.page, () => !!document.querySelector('.call-add-video'), 3000));
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);
console.log('\n[a remote signer\'s way: the candidates inside the offer and answer]');
for (const w of [A, B]) await w.page.evaluate(() => localStorage.setItem('coinos-call-trickle', '0'));
const before = wrapIds.size;
await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
await waitFor(B.page, () => !!document.querySelector('.call-accept'), 20000);
await B.page.evaluate(() => document.querySelector('.call-accept').click());
check('it connects', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000));
await sleep(1500);
const m2 = await media(A.page);
check('...picture and sound', m2.video && m2.audio, JSON.stringify(m2));
const sent = wrapIds.size - before;
check('...with a handful of messages, not one per candidate (offer, answer, its copy for my other devices)', sent <= 3, sent + ' wraps');
await A.page.evaluate(() => document.querySelector('.call-hang').click());
await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);
for (const w of [A, B]) await w.page.evaluate(() => localStorage.removeItem('coinos-call-trickle'));
console.log('\n[a stranger]');
await openDm(C, B.pk);
await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
await sleep(5000);
check('someone Bob neither follows nor wrote to doesn\'t ring him', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
await waitFor(C.page, () => !document.querySelector('.call-overlay'), 6000);
await C.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
await waitFor(C.page, () => /calling/i.test(document.querySelector('.call-overlay')?.innerText || ''), 10000);
await sleep(4000);
check('...nor does their next call (a logged call isn\'t having written to them)', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
await C.page.evaluate(() => document.querySelector('.call-hang')?.click());
await openDm(B, C.pk);
check('...but it\'s in his conversation with them, as a call that didn\'t ring, and why', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText) && /people you follow or have written to/i.test(r.innerText)), 8000),
JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText))));
check('...the caller who gave up has it as cancelled', await waitFor(C.page, () => [...document.querySelectorAll('.chat-row.call-log')].some((r) => /Cancelled voice call/.test(r.innerText)), 6000));
for (const w of [A, B, C]) check(w.name + ': no page errors', !w.page.errs.length, w.page.errs.join(' | '));
} catch (e) { console.error(e); ok = false; } finally {
await browser.close(); server.stop(true);
}
console.log(ok ? '\nā
calls: rung, answered, seen and heard, ended' : '\nā failed');
process.exit(ok ? 0 : 1);
|