aboutsummaryrefslogtreecommitdiffstats
path: root/tools/feed-custom-test.js
blob: 18c1cf0141c278cf62b2286897e27d623afac0cf (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
// Custom feeds: Following is one feed among many. A new feed from the +
// chip (people and/or topics), the filter it asks relays for, a tagged
// post landing in it, switching back, a #tag in a post opening a topic
// feed, and deleting a feed. No relays are contacted for real — the
// WebSocket shim answers the subscriptions.
// Run: bun tools/feed-custom-test.js
import puppeteer from 'puppeteer-core';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
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 SK = generateSecretKey();
const AUTHOR = getPublicKey(SK);
const OTHER_SK = generateSecretKey();
const signed = (content, ago, tags = [], sk = SK) => finalizeEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000) - ago, tags, content }, sk);
const seeded = [signed('hello from the feed, about #bitcoin today', 300), signed('plain post', 600)];

const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5297, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
  window.__reqs = [];
  const subs = [];
  const Real = window.WebSocket;
  window.WebSocket = function (...a) {
    const ws = new Real(...a);
    const send = ws.send.bind(ws);
    ws.send = (data) => {
      try {
        const m = JSON.parse(data);
        if (m[0] === 'REQ' && m[2] && Array.isArray(m[2].kinds) && m[2].kinds.includes(1)) { window.__reqs.push(m[2]); subs.push([ws, m[1], m[2]]); }
      } catch {}
      return send(data);
    };
    return ws;
  };
  window.WebSocket.prototype = Real.prototype;
  Object.assign(window.WebSocket, Real);
  // hand a post to every open kind-1 subscription whose filter it matches
  window.__inject = (ev) => {
    let n = 0;
    for (const [ws, id, f] of subs) {
      if (ws.readyState !== 1) continue;
      if (f.authors && !f.authors.includes(ev.pubkey)) continue;
      if (f['#t'] && !ev.tags.some((t) => t[0] === 't' && f['#t'].includes(t[1]))) continue;
      ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
      n++;
    }
    return n;
  };
});
const errs = [];
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().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { 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 chips = () => page.evaluate(() => [...document.querySelectorAll('.feed-chip')].map((c) => (c.classList.contains('on') ? '*' : '') + c.textContent.trim()));
const type = async (sel, text) => { await page.click(sel, { clickCount: 3 }); await page.type(sel, text); };

try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto('http://localhost:5297/', { 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(500);
  await click('import existing'); await sleep(400);
  await page.waitForSelector('textarea');
  const mn = generateMnemonic(wordlist);
  await page.type('textarea', mn);
  await click('open wallet');
  await waitText('receive', 20000);
  const base = cacheKeyFor(mn + '\n');
  await page.evaluate(([k, pk, ns]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
  }, [base, AUTHOR, seeded]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
  await sleep(800);
  await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); });
  await sleep(1200);
  check('the feed opens on Following, with a + chip', JSON.stringify(await chips()) === JSON.stringify(['*Following', '+']), JSON.stringify(await chips()));

  console.log('\n[a new feed: everyone I follow, on one topic]');
  await page.click('.feed-chip.add'); await sleep(300);
  check('the editor opens', await waitText('new feed', 3000));
  await type('input[placeholder="e.g. Bitcoin builders"]', 'Coin talk');
  await page.click('input[type=checkbox]');
  await type('input[placeholder="#bitcoin #nostr"]', '#Bitcoin, lightning');
  await click('save'); await sleep(1500);
  check('the new feed is on screen and selected', JSON.stringify(await chips()) === JSON.stringify(['Following', '*Coin talk', '+']), JSON.stringify(await chips()));
  const req = (await page.evaluate(() => window.__reqs)).find((f) => f['#t']);
  check('it asks relays for the follows AND the topics', !!req && req.authors?.includes(AUTHOR) && JSON.stringify(req['#t']) === JSON.stringify(['bitcoin', 'lightning']), JSON.stringify(req || null));
  const tagged = signed('fresh #bitcoin post', 0, [['t', 'bitcoin']]);
  const n = await page.evaluate((ev) => window.__inject(ev), tagged);
  await sleep(2800);
  check('a tagged post lands in it', n > 0 && await waitText('fresh #bitcoin post', 4000), n + ' sub(s)');
  const untagged = signed('an untagged post', 0);
  await page.evaluate((ev) => window.__inject(ev), untagged); await sleep(1500);
  check('an untagged one does not', !(await page.evaluate(() => document.body.innerText)).includes('an untagged post'));

  console.log('\n[back to Following, and a #tag in a post]');
  await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === 'Following').click()); await sleep(1200);
  check('Following again, with its own posts', (await chips())[0] === '*Following' && await waitText('plain post', 4000));
  await page.evaluate(() => { const a = [...document.querySelectorAll('.notes-feed a')].find((x) => x.textContent === '#bitcoin'); if (a) a.click(); }); await sleep(1200);
  check('a #tag opens a topic feed of its own', JSON.stringify(await chips()) === JSON.stringify(['Following', 'Coin talk', '*#bitcoin', '+']) && await page.evaluate(() => !![...document.querySelectorAll('button')].find((b) => /save feed/i.test(b.textContent))), JSON.stringify(await chips()));
  const treq = (await page.evaluate(() => window.__reqs)).filter((f) => f['#t'] && !f.authors).pop();
  check('...asked for by topic alone', !!treq && JSON.stringify(treq['#t']) === JSON.stringify(['bitcoin']), JSON.stringify(treq || null));

  console.log('\n[the feeds survive a reload; a feed can be deleted]');
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); await sleep(800);
  await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); await sleep(1200);
  check('the saved feed is still there; the session topic is not', JSON.stringify(await chips()) === JSON.stringify(['*Following', 'Coin talk', '+']), JSON.stringify(await chips()));
  await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].find((c) => c.textContent.trim() === 'Coin talk').click()); await sleep(800);
  await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /edit feed/i.test(b.getAttribute('aria-label') || '')).click()); await sleep(400);
  check('the editor shows what was saved', await page.evaluate(() => document.querySelector('input[placeholder="#bitcoin #nostr"]').value === '#bitcoin #lightning' && document.querySelector('input[type=checkbox]').checked));
  await click('delete feed'); await sleep(1000);
  check('deleting it lands back on Following', JSON.stringify(await chips()) === JSON.stringify(['*Following', '+']), JSON.stringify(await chips()));
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ feeds: Following is one of many' : '\n❌ failed');
process.exit(ok ? 0 : 1);