aboutsummaryrefslogtreecommitdiffstats
path: root/tools/nip29-group-test.js
blob: ddc7258b5a47dbe46207f933d2ad6ab191199781 (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
// NIP-29 relay groups in Messages: New group → Relay group → paste an
// address → Join. Reads the real "pyramid runners" group off
// pyramid.fiatjaf.com (relays: 'read' — nothing published leaves the page:
// the join request, the kind 10009 list and the test message are answered
// OK in place and checked here).
// Run: bun tools/nip29-group-test.js
import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
import { buildHtml } from '../build.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 ADDR = process.env.GROUP || "https://chachi.chat/pyramid.fiatjaf.com/Tnq7x2ZTgrPZWFrC";
const GID = 'Tnq7x2ZTgrPZWFrC';

const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
const errs = [];
page.on('console', (m) => { if (/nip29/.test(m.text())) console.log('  [page]', m.text()); });
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === x); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const published = () => page.evaluate(() => window.__relayGuard.events);

try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await page.evaluate(() => [...document.querySelectorAll('button')].find((n) => /create a new wallet/i.test(n.textContent))?.click()); await sleep(600);
  await seedWords(page);
  const phrase = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
  await skipSeedCheck(page);
  await waitText('receive', 20000);
  await page.click('[aria-label="Messages"]');
  await waitText('new group', 10000);
  await click('new group'); await sleep(300);
  check('New group offers a relay group', await click('relay group'));
  await sleep(200);
  await page.type('.nip29-address', 'not an address');
  await click('join'); await sleep(500);
  check('a bad address is refused', await waitText("isn't a group address", 3000));
  await page.evaluate(() => { const i = document.querySelector('.nip29-address'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); });
  await page.type('.nip29-address', ADDR);
  await click('join');
  check('the group opens, named by its relay', await waitText('pyramid runners', 20000));
  check('its messages arrive', await waitText('paff.club', 20000));
  const evs = await published();
  const join = evs.find((e) => e.kind === 9021);
  check('a join request went to the group', !!join && join.tags.some((x) => x[0] === 'h' && x[1] === GID));
  // the 10009 write reads the newest list first, everywhere (a first write)
  let list = null;
  for (let i = 0; i < 80 && !list; i++) { list = (await published()).filter((e) => e.kind === 10009).pop(); if (!list) await sleep(250); }
  check('the group is added to the kind 10009 list', !!list && list.tags.some((x) => x[0] === 'group' && x[1] === GID && /pyramid\.fiatjaf\.com/.test(x[2])), JSON.stringify(list?.tags));

  // say something (answered OK in place, never sent)
  await page.click('#msg-draft');
  await page.type('#msg-draft', 'hello from the nip29 test');
  await page.keyboard.press('Enter');
  await sleep(1500);
  const msg = (await published()).find((e) => e.kind === 9 && e.content === 'hello from the nip29 test');
  check('a message is a kind 9 with the group h tag', !!msg && msg.tags.some((x) => x[0] === 'h' && x[1] === GID), JSON.stringify(msg?.tags));
  check('...and previous tags from the timeline', !!msg && msg.tags.some((x) => x[0] === 'previous' && /^[0-9a-f]{8}$/.test(x[1])));
  check('it shows in the group', await waitText('hello from the nip29 test', 3000));

  // members: the relay's list
  await page.evaluate(() => document.querySelector('.chat-head .clickable')?.click()); await sleep(800);
  check('the member panel says it is a relay group', await waitText('not encrypted', 3000));
  const n = await page.evaluate(() => document.querySelectorAll('.chat-members .chat-thread-row').length);
  check('members come from the relay', n > 10, n + ' members');

  // back home: a row of its own; reload keeps it
  await page.evaluate(() => document.querySelector('.chat-head button')?.click()); await sleep(600);
  check('the group is listed in Messages', await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.click('[aria-label="Messages"]');
  let kept = false;
  for (let i = 0; i < 40 && !kept; i++) { kept = await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))); if (!kept) await sleep(250); }
  check('...and still is after a reload', kept);

  // the same wallet on another device, already open: the laptop's list
  // arrives on its live 10009 subscription and the group appears there
  // ("Not seeing the pyramid runners chat I joined … on my laptop appear on
  // my phone", Adam, 2026-11-12)
  {
    const ctx = await browser.createBrowserContext();
    const p2 = await ctx.newPage();
    p2.on('console', (m) => { if (process.env.DEBUG && /nip29|chat|10009/.test(m.text())) console.log('  [p2]', m.text()); });
    p2.on('pageerror', (e) => errs.push('p2: ' + e));
    await p2.evaluateOnNewDocument(() => {
      window.__subs = [];
      const send = WebSocket.prototype.send;
      WebSocket.prototype.send = function (d) {
        try { const m = JSON.parse(d); if (m[0] === 'REQ') window.__subs.push({ ws: this, id: m[1], filters: m.slice(2) }); } catch {}
        return send.call(this, d);
      };
    });
    const t2 = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await p2.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
    await p2.setViewport({ width: 390, height: 844 });
    await p2.goto(server.url.href, { waitUntil: 'domcontentloaded' });
    await p2.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
    await p2.reload({ waitUntil: 'domcontentloaded' });
    await t2('sign in to an existing account');
    await p2.evaluate(() => document.getElementById('onb-signin')?.click()); await sleep(500);
    await p2.evaluate(() => document.getElementById('onb-restore')?.click()); await sleep(600);
    await p2.type('textarea[placeholder*="seed words" i]', phrase);
    await p2.evaluate(() => [...document.querySelectorAll('button')].find((n) => /open wallet/i.test(n.textContent))?.click());
    await t2('receive');
    await p2.click('[aria-label="Messages"]'); await sleep(500);
    let subbed = false;
    for (let i = 0; i < 60 && !subbed; i++) { subbed = await p2.evaluate(() => window.__subs.some((x) => x.ws.readyState === 1 && x.filters.some((f) => (f.kinds || []).includes(10009) && f.limit === 1))); if (!subbed) await sleep(250); }
    check('another device follows the 10009 list live', subbed);
    const before = await p2.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners')));
    const fromLaptop = list; // the laptop's own signed list, from before its reload
    await p2.evaluate((ev) => {
      for (const x of window.__subs.filter((x) => x.ws.readyState === 1 && x.filters.some((f) => (f.kinds || []).includes(10009) && f.limit === 1)))
        x.ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', x.id, ev]) }));
    }, fromLaptop);
    let shown = false;
    for (let i = 0; i < 60 && !shown; i++) { shown = await p2.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))); if (!shown) await sleep(250); }
    check('...and the group joined on the laptop appears there', !before && shown);
    // a group whose relay is gone says so on its row, not "0 members"
    await p2.evaluate(() => [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === 'new group')?.click()); await sleep(300);
    await p2.evaluate(() => [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === 'relay group')?.click()); await sleep(200);
    await p2.type('.nip29-address', "gone.invalid'deadgroup");
    await p2.evaluate(() => [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === 'join')?.click()); await sleep(1500);
    await p2.evaluate(() => document.querySelector('.chat-head button')?.click()); await sleep(500);
    let dead = '';
    for (let i = 0; i < 60 && !/answering/.test(dead); i++) { dead = await p2.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => r.textContent.includes('deadgroup'))?.textContent || ''); if (!/answering/.test(dead)) await sleep(250); }
    check("a group on a relay that's gone says so", /gone\.invalid isn't answering/.test(dead), dead.slice(0, 80));
    // leaving it from its own screen ("Tried to leave but got this": a
    // ReferenceError on the screen of a group that had just gone)
    await p2.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => r.textContent.includes('deadgroup'))?.click()); await sleep(800);
    await p2.click('[aria-label="Leave group"]'); await sleep(200);
    await p2.click('[aria-label="Leave group"]'); await sleep(2500);
    const after = await p2.evaluate(() => document.body.innerText);
    check('leaving a group from its screen goes back to Messages', !/something broke/i.test(after) && !/deadgroup/.test(after) && /new group/i.test(after), after.slice(0, 120));
    await ctx.close();
  }

  // leave: two taps, a 9022 to the relay, and off the list
  await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => r.textContent.includes('pyramid runners'))?.click()); await sleep(800);
  await page.click('[aria-label="Leave group"]'); await sleep(200);
  await page.click('[aria-label="Leave group"]');
  let left = null, relist = null;
  for (let i = 0; i < 80 && !(left && relist); i++) {
    const evs = await published();
    left = evs.find((e) => e.kind === 9022);
    relist = evs.filter((e) => e.kind === 10009).find((e) => !e.tags.some((x) => x[0] === 'group'));
    if (!(left && relist)) await sleep(250);
  }
  check('leaving tells the relay (9022)', !!left && left.tags.some((x) => x[0] === 'h' && x[1] === GID));
  check('...and takes it off the 10009 list', !!relist);
  check('...and out of Messages', !(await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners')))));
  check('no page errors', !errs.length, errs.join(' | '));
} catch (e) { console.error(e); ok = false; } finally {
  await browser.close(); server.stop();
}
console.log(ok ? '\nPASS' : '\nFAIL');
process.exit(ok ? 0 : 1);