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