aboutsummaryrefslogtreecommitdiffstats
path: root/tools/call-wake-test.js
blob: 54c42eeb6aa9610bfb28eefb5a8ad2fc52e88f31 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// A call wakes a closed app: with Bob's coinos closed, Alice calls; the push
// the notifier would send (delivered here to Bob's service worker through
// Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open
// it with Bob's key and ring — "Alice Caller is calling" — and the tap that
// opens the app finds the offer kept for it: it rings there, Bob answers,
// they talk. A push for a stranger's call, or for something that isn't an
// offer, rings nothing. Plus the worker's check on its own (classifyCall).
// Run: bun tools/call-wake-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, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
import { bytesToHex } from '@noble/hashes/utils';
import { npubEncode } from 'nostr-tools/nip19';
import { classifyCall } from '../src/dm-inbox.js';
import { callEvent, wrapCall, CALL } from '../src/calls.js';
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 = 5306;

console.log('[the worker\'s check]');
{
  const bob = generateSecretKey(), alice = generateSecretKey(), eve = generateSecretKey();
  const B = getPublicKey(bob), Al = getPublicKey(alice), E = getPublicKey(eve);
  const box = [{ pubkey: B, sk: bytesToHex(bob), follows: [Al], known: [], muted: [], names: { [Al]: 'Alice' } }];
  const offer = (sk, ago = 0, kind = CALL.OFFER, to = B) => wrapCall(finalizeEvent({ ...callEvent(kind, to, 'id-1', 'v=0', kind === CALL.OFFER ? [['call-type', 'video']] : []), created_at: Math.floor(Date.now() / 1000) - ago }, sk), to);
  const r = await classifyCall(offer(alice), box);
  check('a fresh offer from someone followed rings, named, video', !!r && !r.quiet && r.name === 'Alice' && r.type === 'video' && r.offer.pubkey === Al);
  check('...a stranger\'s doesn\'t ring, but is kept as quiet', (await classifyCall(offer(eve), box))?.quiet === true);
  check('...nor someone muted', (await classifyCall(offer(alice), [{ ...box[0], muted: [Al] }])) === null);
  check('...but someone written to rings', (await classifyCall(offer(eve), [{ ...box[0], rings: [E] }]))?.quiet === false);
  check('...not someone who only wrote to us', (await classifyCall(offer(eve), [{ ...box[0], known: [E], rings: [] }]))?.quiet === true);
  check('...an offer over a minute old doesn\'t', (await classifyCall(offer(alice, 90), box)) === null);
  check('...nor a candidate or a hang-up', (await classifyCall(offer(alice, 0, CALL.ICE), box)) === null && (await classifyCall(offer(alice, 0, CALL.HANGUP), box)) === null);
  check('...nor a wrap for someone else', (await classifyCall(offer(alice, 0, CALL.OFFER, E), box)) === null);
}

if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
const stored = [], subs = new Set(), callWraps = new Map();
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))
  && 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 });
    const w = /^\/nwcpush\/wrap\/([0-9a-f]{64})$/.exec(path); // the notifier's keeping of call wraps
    if (w) return callWraps.has(w[1]) ? Response.json(callWraps.get(w[1])) : Response.json({ error: 'gone' }, { status: 404 });
    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 = new Map(); },
    message(ws, raw) {
      let m; try { m = JSON.parse(raw); } catch { return; }
      if (m[0] === 'EVENT') {
        const e = m[1];
        ws.send(JSON.stringify(['OK', e.id, true, '']));
        if (e.kind === 21059) callWraps.set(e.id, e); else 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, 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.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') subs.delete(ws.data.get(m[1]));
    },
    close(ws) { for (const s of ws.data.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'] });
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 context = await browser.createBrowserContext();
  await context.overridePermissions(`http://localhost:${PORT}`, ['notifications']);
  const page = await context.newPage();
  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');
  const sk = privateKeyFromSeedWords(mn);
  return { context, page, mn, sk, pk: getPublicKey(sk), 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; };
// the push the notifier sends, delivered to a context's service worker
async function push(w, data) {
  const cdp = await w.page.target().createCDPSession();
  const regs = [];
  cdp.on('ServiceWorker.workerRegistrationUpdated', (e) => regs.push(...e.registrations));
  await cdp.send('ServiceWorker.enable');
  for (let i = 0; i < 25 && !regs.some((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted); i++) await sleep(200);
  const reg = regs.find((r) => r.scopeURL.startsWith(`http://localhost:${PORT}`) && !r.isDeleted);
  await cdp.send('ServiceWorker.deliverPushMessage', { origin: `http://localhost:${PORT}`, registrationId: reg.registrationId, data: JSON.stringify(data) });
  await cdp.detach();
}
const notifications = (page) => page.evaluate(async () => (await (await navigator.serviceWorker.ready).getNotifications()).map((n) => n.title + ' | ' + n.body + ' | ' + n.tag));

try {
  console.log('\n[a closed app, rung]');
  const A = await wallet('alice'), B = await wallet('bob');
  const profiles = { [A.pk]: { name: 'Alice Caller', t: Date.now() }, [B.pk]: { name: 'Bob Callee', t: Date.now() } };
  // their follow lists, where the app (and Bob's worker mirror) read them
  for (const [w, other] of [[A, B], [B, A]]) {
    // (newer than the starter list a new wallet publishes for itself)
    stored.push(finalizeEvent({ kind: 3, created_at: Math.floor(Date.now() / 1000) + 120, content: '', tags: [['p', other.pk]] }, w.sk));
    await w.page.evaluate(([k, pk, profiles]) => {
      localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
      localStorage.setItem(k + ':profiles', JSON.stringify(profiles));
    }, [w.base, other.pk, profiles]);
    await w.page.reload({ waitUntil: 'domcontentloaded' });
    await w.waitText('receive');
  }
  check('Bob\'s app has given his worker what it needs: his key, his follows', await waitFor(B.page, (pk) => new Promise((res) => {
    const r = indexedDB.open('coinos-dm-inbox');
    r.onsuccess = () => { try { const g = r.result.transaction('inbox').objectStore('inbox').getAll(); g.onsuccess = () => res(g.result.some((x) => x.sk && (x.follows || []).includes(pk))); g.onerror = () => res(false); } catch { res(false); } };
    r.onerror = () => res(false);
  }), 20000, A.pk));
  await B.page.evaluate(() => navigator.serviceWorker.ready);
  // Bob closes coinos
  await B.page.goto('about:blank');
  // Alice calls
  await A.page.goto(`http://localhost:${PORT}/` + npubEncode(B.pk), { waitUntil: 'domcontentloaded' });
  await A.page.waitForSelector('.prof-actions', { timeout: 20000 });
  await A.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
  await A.page.waitForSelector('.chat-head .call-start', { timeout: 10000 });
  const wrapsBefore = new Set(callWraps.keys());
  await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
  let offerWrap = null;
  for (let i = 0; i < 50 && !offerWrap; i++) { await sleep(200); offerWrap = [...callWraps.values()].find((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); }
  check('Alice\'s offer goes out, big enough for the notifier to wake Bob for', !!offerWrap, offerWrap && offerWrap.content.length + ' chars');
  const small = [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.content.length < 2000);
  check('...and what follows it is small enough not to', small.length >= 1 && small.every((w) => w.content.length < 1500), small.map((w) => w.content.length).join(','));
  await push(B, { type: 'notify', reason: 'call', wrapId: offerWrap.id });
  await sleep(1500);
  // the tap: the worker opens the app at ?open=call
  await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' });
  const ns = await notifications(B.page);
  check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns));
  check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000));
  // Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one)
  const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length;
  const ringingCands = toBob();
  await B.page.evaluate(() => document.querySelector('.call-accept').click());
  check('Bob answers: they\'re connected', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 25000)
    && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
  check('Alice sends her candidates again once answered (Bob\'s app heard none of the first ones)', toBob() >= 2 * ringingCands && ringingCands > 0, ringingCands + ' → ' + toBob());
  check('...and hear each other', await waitFor(B.page, () => { const a = document.querySelector('audio.call-audio'); return !!a && !!a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live'); }, 5000));
  check('the "is calling" notification is gone once answered', await waitFor(B.page, async () => !(await (await navigator.serviceWorker.ready).getNotifications({ tag: 'call' })).length, 5000));
  await A.page.evaluate(() => document.querySelector('.call-hang').click());
  await waitFor(B.page, () => !document.querySelector('.call-overlay'), 12000);

  console.log('\n[pushes that ring nothing]');
  await B.page.goto('about:blank');
  const eve = generateSecretKey();
  const strangers = wrapCall(finalizeEvent(callEvent(CALL.OFFER, B.pk, 'x-1', 'v=0\r\n' + 'a'.repeat(2400), [['call-type', 'voice']]), eve), B.pk);
  callWraps.set(strangers.id, strangers);
  await push(B, { type: 'notify', reason: 'call', wrapId: strangers.id });
  await push(B, { type: 'notify', reason: 'call', wrapId: 'f'.repeat(64) }); // one the notifier no longer has
  await sleep(1500);
  await B.page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
  await B.waitText('receive');
  check('a stranger\'s offer, or one gone, rings nothing', !(await notifications(B.page)).some((n) => /calling/.test(n)) && !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
  await B.page.goto(`http://localhost:${PORT}/` + npubEncode(getPublicKey(eve)), { waitUntil: 'domcontentloaded' });
  await B.page.waitForSelector('.prof-actions', { timeout: 20000 });
  await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
  check('...but the stranger\'s call is in the app once it opens: missed, didn\'t ring', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /didn.t ring/i.test(r.innerText)), 10000));

  console.log('\n[rung while closed, and nobody came]');
  await B.page.goto('about:blank');
  const before2 = new Set(callWraps.keys());
  await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click());
  let offer2 = null;
  for (let i = 0; i < 50 && !offer2; i++) { await sleep(200); offer2 = [...callWraps.values()].find((w) => !before2.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length >= 2000); }
  await push(B, { type: 'notify', reason: 'call', wrapId: offer2.id });
  await sleep(1500);
  await A.page.evaluate(() => document.querySelector('.call-hang').click()); // Alice gives up
  // Bob opens coinos later on, past the call's minute (the kept offer dated back)
  const page2 = await B.context.newPage();
  await page2.goto(`http://localhost:${PORT}/icon-192.png`, { waitUntil: 'domcontentloaded' }); // the origin, without the app (which would take the offer)
  await page2.evaluate(async () => {
    const c = await caches.open('coinos-call'), r = await c.match('/__pending-call');
    const j = await r.json(); j.at -= 120_000;
    await c.put('/__pending-call', new Response(JSON.stringify(j), { headers: { 'content-type': 'application/json' } }));
  });
  await page2.close();
  await B.page.goto(`http://localhost:${PORT}/` + npubEncode(A.pk), { waitUntil: 'domcontentloaded' });
  check('the app doesn\'t ring for a call long over', !(await B.page.evaluate(() => !!document.querySelector('.call-overlay'))));
  await B.page.waitForSelector('.prof-actions', { timeout: 20000 });
  await B.page.evaluate(() => [...document.querySelectorAll('.prof-actions button')].find((x) => /message/i.test(x.textContent)).click());
  check('...it\'s a missed call in the conversation', await waitFor(B.page, () => [...document.querySelectorAll('.chat-row.call-log.missed')].some((r) => /Missed voice call/.test(r.innerText)), 10000),
    JSON.stringify(await B.page.evaluate(() => [...document.querySelectorAll('.chat-row.call-log')].map((r) => r.innerText))));
  for (const w of [A, B]) 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✅ a call wakes a closed app' : '\n❌ failed');
process.exit(ok ? 0 : 1);