// 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(
let config = require('./config');
let fs = require('fs');

let mailgun = require('mailgun-js')({apiKey: config.mailgun, domain: config.domain });
 
let data = {
  ...config.mail,
  subject: 'User Documents',
  text: 'Documents uploaded',
};

fs.readdir('./uploads', (err, files) => {
  files.forEach(file => {
    console.log(file);
    data.text = file;
    fs.rename(file, '../verified' + file);
    mailgun.messages().send(data, function (error, body) {
      console.log(body);
    });
  });
});
 
der^="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);