aboutsummaryrefslogtreecommitdiffstats
path: root/tools/call-test.js
blob: 96271fb21aaf5f7f919e23e08dd72fb51a34d082 (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
// 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 (neither followed nor written
// to) doesn't ring at all. 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;
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 });
    if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 }); // two tabs on one machine: no TURN needed
    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') {
        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));
  await B.page.evaluate(() => document.querySelector('.call-accept').click());
  check('answered: both connected, the clock running', 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));

  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);

  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());
  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);