aboutsummaryrefslogtreecommitdiffstats
path: root/tools/feed-follow-test.js
blob: f0f688c04bf3daa5c5897ac8cff8b01b2bf50c25 (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
// Follows and the feed: the Follow button reflects the contact list, the feed
// reads the people on it, and a brand-new wallet is told what to do instead
// of being shown an empty box.
//
// Nothing here publishes to a relay — the contact list is seeded locally, the
// way it would arrive from one.
//
// Run: bun tools/feed-follow-test.js
import puppeteer from './puppeteer.js';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { cacheKeyFor } from '../src/wallet.js';
import { npubEncode } from 'nostr-tools/nip19';
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 html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5237, 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();
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);
// the Feed is a tab in the bottom nav (it used to be offered on the messages home)
const openFeed = () => page.evaluate(() => { const b = [...document.querySelectorAll('.app-nav-button')].find((e) => e.getAttribute('aria-label') === 'Feed'); if (b) b.click(); return !!b; });
const chip = (name) => page.evaluate((n) => { const e = [...document.querySelectorAll('.feed-chips > .feed-chip')].find((b) => b.textContent.trim() === n); if (e) e.click(); return !!e; }, name);
const text = () => page.evaluate(() => document.body.innerText);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const FOLLOWED = 'e'.repeat(63) + '1';
const OTHER = 'd'.repeat(63) + '2';
try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto('http://localhost:5237/', { 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('to import'); await sleep(400);
  await page.waitForSelector('textarea');
  const mnemonic = generateMnemonic(wordlist);
  await page.type('textarea', mnemonic);
  await click('open wallet');
  await waitText('receive', 20000);
  const base = cacheKeyFor(mnemonic + '\n');

  check('the bottom nav has a Feed tab', await openFeed());
  await sleep(1000);
  // a new wallet opens on Popular; Following is the people you follow
  check('...whose chips include Following', await chip('Following'));
  await sleep(1000);
  check('a wallet that follows nobody is told so', /don't follow anyone yet/i.test(await text()));
  check('...and offered somewhere to start', /find people/i.test(await text()));

  // a contact list, as a relay would hand it over
  await page.evaluate(([k, pk]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
  }, [base, FOLLOWED]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await openFeed();
  await sleep(1200);
  // (the feed you were on, Following, is remembered)
  check('Following no longer says you follow nobody', /\bFollowing\b/.test(await page.evaluate(() => document.querySelector('.feed-chips > .feed-chip.on')?.textContent || ''))
    && !/don't follow anyone yet/i.test(await text()));

  // and their own profile page knows we follow them
  await page.goto('http://localhost:5237/' + npubEncode(FOLLOWED), { waitUntil: 'domcontentloaded' });
  await sleep(6000);
  const btns = await page.evaluate(() => [...document.querySelectorAll('button')].map((b) => b.textContent.trim()));
  check('their profile offers to unfollow, not to follow', btns.includes('Following') && !btns.includes('Follow'),
    btns.filter((b) => /follow/i.test(b)).join(',') || 'no follow button');

  // Outbox: each author is read on the relays they publish to, not only ours.
  // Every socket the page opens is recorded, so the plan can be checked by
  // what it actually dialled.
  await page.evaluateOnNewDocument(() => {
    window.__dialled = [];
    const Real = window.WebSocket;
    window.WebSocket = function (url, ...rest) { window.__dialled.push(String(url)); return new Real(url, ...rest); };
    window.WebSocket.prototype = Real.prototype;
    Object.assign(window.WebSocket, Real);
  });
  await page.goto('http://localhost:5237/', { waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(([k, a, b]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a], ['p', b]], c: '', at: Math.floor(Date.now() / 1000) }));
    // relay lists as NIP-65 would have given them
    localStorage.setItem(k + ':relayLists', JSON.stringify({
      [a]: { r: ['wss://alice.example.invalid'], t: Date.now() },
      [b]: { r: ['wss://bob.example.invalid'], t: Date.now() },
    }));
  }, [base, FOLLOWED, OTHER]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await openFeed();
  await sleep(6000);
  const dialled = await page.evaluate(() => window.__dialled || []);
  check('each author is read on their own write relay',
    dialled.some((u) => u.includes('alice.example.invalid')) && dialled.some((u) => u.includes('bob.example.invalid')),
    dialled.filter((u) => u.includes('example.invalid')).join(' ') || 'neither dialled');
  check('...and our own relays are still asked as well',
    dialled.some((u) => u.includes('relay.coinos.io')), dialled.length + ' sockets');

  // Covering every author once is not the same as reading them. People
  // publish to several relays and each holds a different slice — measured
  // over 250 real authors, seven of twenty-five sampled had posts living
  // only on their fourth or later write relay, and spending the whole socket
  // budget instead of stopping at first coverage found a quarter more posts.
  // So a lone author with six relays must be read on all six, not three.
  await page.evaluate(([k, a]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', a]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':relayLists', JSON.stringify({
      [a]: { r: [1, 2, 3, 4, 5, 6].map((n) => 'wss://r' + n + '.example.invalid'), t: Date.now() },
    }));
  }, [base, FOLLOWED]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await openFeed();
  await sleep(6000);
  const wide = await page.evaluate(() => window.__dialled || []);
  const hit = [1, 2, 3, 4, 5, 6].filter((n) => wide.some((u) => u.includes('r' + n + '.example.invalid')));
  check('an author who publishes to six relays is read on all six',
    hit.length === 6, 'reached ' + (hit.join(',') || 'none'));

  // The feed opens at ten posts and grows as you reach the bottom, however
  // many it is holding. (Tall posts: a page of short ones doesn't reach the
  // distance from the bottom where the next page is read.)
  await page.evaluate((k) => {
    const notes = [];
    const now = Math.floor(Date.now() / 1000);
    for (let i = 0; i < 60; i++) notes.push({ id: String(i).padStart(64, '0'), pubkey: 'f'.repeat(64), kind: 1, created_at: now - i * 60, content: 'post ' + i + ' ' + 'with enough words to stand tall '.repeat(30), tags: [] });
    localStorage.setItem(k + ':feedNotes', JSON.stringify(notes));
  }, base);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await openFeed();
  await sleep(1500);
  const first = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length);
  check('the feed opens at ten posts, not sixty', first === 10, first + ' rows');
  await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
  await sleep(1200);
  const second = await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length);
  check('reaching the bottom shows ten more', second === 20, second + ' rows');
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ follows and feed' : '\n❌ failed');
process.exit(ok ? 0 : 1);