aboutsummaryrefslogtreecommitdiffstats
path: root/tools/call-test.js
blob: 5bfa38cfb7cd5a2c98d221102e860e5c1c8bf506 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
// 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'] });
// 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));
  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 }));
  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 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);