aboutsummaryrefslogtreecommitdiffstats
path: root/tools/marmot-chat-test.js
blob: c9323363cbc832cec0e63ae1e3d1bf5682c26705 (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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
// Group chats (White Noise) in the real UI, against the Rust reference client.
//
//   bun tools/marmot-chat-test.js
//
// Same prerequisites as tools/marmot-interop-test.js (a local relay and a
// running `wn` daemon); skips cleanly without them. Every relay socket the
// page opens is pointed at the local relay, so nothing leaves the machine.
//
// Against a deployed site instead (real relays, throwaway identities):
//   SITE=https://v3.coinos.io RELAY=wss://relay.coinos.io bun tools/marmot-chat-test.js
// with the wn daemon started on public relays (e.g. wss://nos.lol).
//
// Covers: becoming invitable (KeyPackage published at boot), a wn group
// invitation → card → Join → messages both ways; "New message" to a White
// Noise user founding a direct group whose replies land in the DM thread;
// "New group" from the app.
import puppeteer from './puppeteer.js';
import { spawnSync } from 'node:child_process';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { buildHtml } from '../build.js';
import { seedPubkey } from '../src/nostr.js';
import * as nip06 from 'nostr-tools/nip06';
import { finalizeEvent } from 'nostr-tools/pure';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { randomBytes, bytesToHex } from '@noble/hashes/utils';

// a tiny real PNG with a few random bytes after it, so every run's file is new
const png = () => new Uint8Array([...Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP8z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0)), ...randomBytes(8)]);
const imgShown = () => page.evaluate(() => [...document.querySelectorAll('.chat-log img.note-img')].filter((i) => i.complete && i.naturalWidth > 0 && i.src.startsWith('blob:')).length);

// shots go somewhere every machine has
const shot = (name) => join(tmpdir(), name);

const RELAY = process.env.RELAY || 'ws://127.0.0.1:27777';
const SITE = process.env.SITE || '';
const PORT = 5247;
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; };

function wn(args, account) {
  const r = spawnSync('wn', ['--json', ...(account ? ['--account', account] : []), ...args], { encoding: 'utf8' });
  const line = (r.stdout || '').trim().split('\n').filter(Boolean).at(-1) || '';
  try { const j = JSON.parse(line); return j.ok ? j.result : { error: j.error }; } catch { return { error: { message: (r.stderr || 'no output').trim() } }; }
}
function relayQuery(filter) {
  return new Promise((resolve) => {
    const ws = new WebSocket(RELAY), out = [];
    const done = () => { try { ws.close(); } catch {} resolve(out); };
    ws.onopen = () => ws.send(JSON.stringify(['REQ', 'q', filter]));
    ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d[0] === 'EVENT') out.push(d[2]); if (d[0] === 'EOSE') done(); };
    ws.onerror = done;
    setTimeout(done, 3000);
  });
}
const until = async (fn, ms = SITE ? 45000 : 20000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(500); } };

if (wn(['whoami']).error) { console.log('SKIP: wn daemon not reachable'); process.exit(0); }
if (!(await relayQuery({ kinds: [0], limit: 1 }))) { console.log('SKIP: no relay'); process.exit(0); }
const alice = wn(['create-identity']).account_id, bob = wn(['create-identity']).account_id;

const server = SITE ? null : Bun.serve({
  port: PORT,
  fetch: ((html) => () => new Response(html, { headers: { 'content-type': 'text/html' } }))(await buildHtml({ minify: true, pwa: false })),
});

const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setViewport({ width: 1100, height: 850 });
page.on('pageerror', (e) => { console.log('   page error:', e.message); ok = false; });
// DEBUG=1 turns on the app's debug switch and echoes the group-chat lines
if (process.env.DEBUG) {
  await page.evaluateOnNewDocument(() => localStorage.setItem('coinos-debug', '1'));
  page.on('console', async (m) => {
    if (!/^marmot/.test(m.text())) return;
    const args = await Promise.all(m.args().map((a) => a.evaluate((v) => (v instanceof Error ? v.message : typeof v === 'string' ? v : JSON.stringify(v))).catch(() => '?')));
    console.log('   [page]', args.join(' ').slice(0, 300));
  });
}
// every relay is the local relay
if (!SITE) await page.evaluateOnNewDocument((relay) => {
  const Real = window.WebSocket;
  window.WebSocket = function (url, protocols) { return new Real(/^wss?:/.test(url) ? relay : url, protocols); };
  window.WebSocket.prototype = Real.prototype;
  Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
  localStorage.setItem('coinos-group-relays', JSON.stringify([relay]));
  localStorage.setItem('btc-wallet-network', 'regtest');
}, RELAY);

const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v);
const bodyText = () => page.evaluate(() => document.body.innerText);
const waitText = (x, ms = 20000) => until(async () => (await bodyText()).toLowerCase().includes(x.toLowerCase()), ms);
const say = async (text) => {
  await page.waitForSelector('#msg-draft', { timeout: 15000 });
  await page.evaluate((v) => {
    const e = document.querySelector('#msg-draft');
    const ta = e.shadowRoot ? e.shadowRoot.querySelector('textarea') : (e.querySelector && e.querySelector('textarea')) || e;
    ta.focus();
    ta.value = v;
    ta.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
  }, text);
  await sleep(150);
  await page.keyboard.press('Enter');
};
const openChat = () => page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click());

try {
  const mnemonic = generateMnemonic(wordlist);
  const me = seedPubkey(mnemonic);
  // wn follows an account's relay lists to real URLs; ours must name the
  // local relay before the wallet (which would publish its defaults) opens
  if (!SITE) {
    const sk = nip06.privateKeyFromSeedWords(mnemonic);
    const at = Math.floor(Date.now() / 1000);
    for (const ev of [
      finalizeEvent({ kind: 10002, created_at: at, tags: [['r', RELAY]], content: '' }, sk),
      finalizeEvent({ kind: 10050, created_at: at, tags: [['relay', RELAY]], content: '' }, sk),
    ]) await new Promise((res) => { const ws = new WebSocket(RELAY); ws.onopen = () => ws.send(JSON.stringify(['EVENT', ev])); ws.onmessage = () => { ws.close(); res(); }; ws.onerror = res; });
  }
  await page.goto(SITE || `http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
  await sleep(500);
  await click('button', 'Create a new wallet');
  await sleep(300);
  await click('button', 'Have an existing seed');
  await sleep(300);
  await page.waitForSelector('textarea');
  await page.type('textarea', mnemonic);
  await click('button', 'Open wallet');
  // a mainnet wallet is first offered a username: not now
  await until(async () => /receive|not now/i.test(await bodyText()), 20000);
  for (let i = 0; i < 3 && await click('button', 'Not now'); i++) await sleep(600);
  await waitText('receive', 15000);

  console.log('\n[invitable without doing anything]');
  const kp = await until(async () => (await relayQuery({ kinds: [30443], authors: [me] }))[0], 25000);
  check('a KeyPackage is published after boot', !!kp);
  const chk = wn(['keys', 'check', me]);
  check('wn can use it', !chk.error && JSON.stringify(chk).includes('true'), JSON.stringify(chk).slice(0, 200));

  if (SITE) {
    // White Noise only invites an account whose relay lists it can find on ITS relays
    const dir = 'wss://relay.eu.whitenoise.chat';
    const found = await until(async () => {
      const evs = await new Promise((res) => { const ws = new WebSocket(dir), out = []; ws.onopen = () => ws.send(JSON.stringify(['REQ', 'q', { kinds: [10002, 10050], authors: [me] }])); ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d[0] === 'EVENT') out.push(d[2].kind); else { ws.close(); res(out); } }; ws.onerror = () => res(out); setTimeout(() => res(out), 4000); });
      return evs.includes(10002) && evs.includes(10050);
    }, 40000);
    check('its relay lists are on the White Noise directory relays', !!found);
  }

  console.log('\n[a White Noise group invites us]');
  const made = wn(['groups', 'create', 'Trail crew', me], alice);
  check('wn groups create', !made.error, JSON.stringify(made.error || ''));
  const gid = made.group_id || made.group?.group_id;
  await openChat();
  check('invitation card shows', !!await waitText('invited you to Trail crew', 25000));
  await click('button', 'Join');
  check('the group opens', !!await waitText('2 members'));
  wn(['messages', 'send', gid, 'hello from white noise'], alice);
  // on public relays a dropped socket is only noticed by the watchdog's next pass
  const arrived = !!await waitText('hello from white noise', SITE ? 120000 : 20000);
  check('their message arrives', arrived);
  if (!arrived && process.env.DEBUG) {
    const chat = (wn(['chats', 'list'], alice).chats || []).find((c) => c.group_id === gid) || {};
    const r = chat.nostr_routing || {};
    console.log('   wn routing', JSON.stringify(r.relays), 'last', JSON.stringify(chat.last_message || {}).slice(0, 200));
    console.log('   wn messages', JSON.stringify(wn(['messages', 'list', gid, '--limit', '5'], alice)).slice(0, 400));
    for (const url of r.relays || []) {
      const evs = await new Promise((res) => { const ws = new WebSocket(url), out = []; ws.onopen = () => ws.send(JSON.stringify(['REQ', 'q', { kinds: [445], '#h': [r.nostr_group_id_hex] }])); ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d[0] === 'EVENT') out.push(d[2]); else res(out); }; setTimeout(() => res(out), 4000); });
      console.log('   ', url, 'holds', evs.length, 'group events', evs.map((e) => e.created_at).join(','));
    }
  }
  await say('hello from coinos');
  check('ours reaches wn', !!await until(() => JSON.stringify(wn(['messages', 'list', gid, '--limit', '20'], alice)).includes('hello from coinos')));

  console.log('\n[pictures]');
  {
    const dir = mkdtempSync(join(tmpdir(), 'wn-ui-'));
    const theirs = join(dir, 'from-wn.png');
    writeFileSync(theirs, png());
    const before = await imgShown();
    const up = wn(['media', 'upload', gid, theirs, '--send'], alice);
    check('wn sends a picture', !up.error, JSON.stringify(up.error || ''));
    check('it shows in the group', !!await until(async () => (await imgShown()) > before, SITE ? 90000 : 30000));
    const ours = join(dir, 'from-coinos.png');
    const bytes = png();
    writeFileSync(ours, bytes);
    const input = await page.$('#msg-file');
    await input.uploadFile(ours);
    const plain = bytesToHex((await import('@noble/hashes/sha256')).sha256(bytes));
    check('ours reaches wn', !!await until(() => JSON.stringify(wn(['media', 'list', gid], alice)).includes(plain), 40000));
    const out = mkdtempSync(join(tmpdir(), 'wn-dl-'));
    wn(['media', 'download', gid, plain, '--output', out], alice);
    const f = readdirSync(out)[0];
    check('and wn decrypts it', !!f && bytesToHex(readFileSync(join(out, f))) === bytesToHex(bytes));
  }
  await page.screenshot({ path: shot('marmot-group.png') });

  console.log('\n[a first message to a White Noise user founds a direct chat]');
  await page.evaluate(() => document.querySelector('.chat-head button')?.click()); // back
  await sleep(400);
  await click('button', 'New message');
  await sleep(300);
  await setInput('input[placeholder*="npub" i]', bob);
  await click('button', 'Open');
  await page.waitForSelector('#msg-draft');
  await sleep(2500); // their KeyPackage is looked up when the thread opens
  await say('hi bob, direct');
  const bobChat = await until(() => { const l = wn(['chats', 'list'], bob); const s = JSON.stringify(l); return s.includes('group_id') && !s.includes('"chats":[]') ? l : null; }, 25000);
  check('bob has a chat', !!bobChat, JSON.stringify(wn(['groups', 'invites'], bob)).slice(0, 200));
  const dmGid = JSON.stringify(bobChat || '').match(/"group_id":"([0-9a-f]+)"/)?.[1];
  if (dmGid) {
    wn(['groups', 'accept', dmGid], bob);
    check('bob reads our first message', !!await until(() => JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('hi bob, direct')));
    wn(['messages', 'send', dmGid, 'hey, bob here'], bob);
    check('his reply lands in the same thread', !!await waitText('hey, bob here'));
    await say('second message');
    check('a reply goes back the same way', !!await until(() => JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('second message')));

    console.log('\n[the route can be pinned by hand]');
    const pill = () => page.evaluate(() => document.querySelector('.route-pill')?.textContent || '');
    check('the thread says which way it sends', /Marmot/.test(await pill()), await pill());
    const wrapsBefore = (await relayQuery({ kinds: [1059], '#p': [bob] })).length;
    await page.click('.route-pill');
    await sleep(250);
    await click('.msg-sheet-item', 'Nostr DM');
    await sleep(300);
    check('the pill follows the pin', /Nostr DM/.test(await pill()), await pill());
    await say('this one by dm');
    check('a pinned Nostr DM goes out as a gift wrap', !!await until(async () => (await relayQuery({ kinds: [1059], '#p': [bob] })).length > wrapsBefore, 15000));
    await sleep(1500);
    check('and not into the White Noise chat', !JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('this one by dm'));
    await page.click('.route-pill');
    await sleep(250);
    await click('.msg-sheet-item', 'Automatic');
    await sleep(300);
    check('automatic follows their last message again', /Marmot/.test(await pill()), await pill());
    await say('back on auto');
    check('and sends by White Noise', !!await until(() => JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('back on auto')));
  }
  await page.screenshot({ path: shot('marmot-dm.png') });

  console.log('\n[New group from the app]');
  await page.evaluate(() => document.querySelector('.chat-head button')?.click());
  await sleep(400);
  await click('button', 'New group');
  await sleep(300);
  await setInput('input[placeholder="Group name"]', 'Made in coinos');
  for (const who of [alice, bob]) {
    await setInput('input[placeholder^="Add people"]', who);
    await page.focus('input[placeholder^="Add people"]');
    await page.keyboard.press('Enter');
    await sleep(300);
  }
  await until(async () => !(await bodyText()).includes('checking'), 15000);
  await click('button', 'Create group');
  check('the new group opens', !!await waitText('3 members', 25000));
  const theirs = await until(() => { const s = JSON.stringify(wn(['chats', 'list'], alice)) + JSON.stringify(wn(['groups', 'invites'], alice)); return s.includes('Made in coinos'); }, 25000);
  check('wn sees the group', !!theirs);
  await say('welcome everyone');
  const rows = [...(wn(['chats', 'list'], alice).chats || []), ...(wn(['groups', 'invites'], alice).invites || [])];
  const ng = (rows.find((c) => JSON.stringify(c).includes('Made in coinos')) || {}).group_id;
  if (ng) {
    wn(['groups', 'accept', ng], alice);
    check('wn reads the group message', !!await until(() => JSON.stringify(wn(['messages', 'list', ng, '--limit', '20'], alice)).includes('welcome everyone')));
    wn(['messages', 'send', ng, 'thanks!'], alice);
    check('and answers', !!await waitText('thanks!'));
  } else check('found the group id on the wn side', false, JSON.stringify(wn(['chats', 'list'], alice)).slice(0, 300));
  await page.screenshot({ path: shot('marmot-newgroup.png') });

  console.log('\n[it is all still there after a reload]');
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 15000);
  await openChat();
  check('groups listed after reload', !!await waitText('Made in coinos') && !!await waitText('Trail crew'));
  await page.screenshot({ path: shot('marmot-home.png') });

  console.log('\n[someone without a KeyPackage still gets a NIP-17 DM]');
  const stranger = seedPubkey(generateMnemonic(wordlist));
  await click('button', 'New message');
  await sleep(300);
  await setInput('input[placeholder*="npub" i]', stranger);
  await click('button', 'Open');
  await say('plain old dm');
  check('the message shows', !!await waitText('plain old dm'));
  check('no route choice where there is only one way', !(await page.$('.route-pill')));
  check('a gift wrap went to their inbox', !!await until(async () => (await relayQuery({ kinds: [1059], '#p': [stranger] })).length > 0, 15000));
  check('no group was founded for it', (await relayQuery({ kinds: [1059], '#p': [stranger] })).length <= 1);
} catch (e) { console.log(e); ok = false; }
await browser.close();
if (server) server.stop();
console.log(ok ? '\nall passed' : '\nFAILED');
process.exit(ok ? 0 : 1);