aboutsummaryrefslogtreecommitdiffstats
path: root/tools/feed-reboost-test.js
blob: e22df91748a2b54db9ffd7e857a5d614ee48e5a1 (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
// A post you've read doesn't come back to the top of the feed when someone
// boosts it again: a row held on screen for a second counts as read (kept on
// this device), and a later repost of that post — live, or found after a
// reload — stays out. A repost of a post you haven't read still lands, and
// the row you read keeps its place.
// Run: bun tools/feed-reboost-test.js
import puppeteer from './puppeteer.js';
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 A = generateSecretKey(), B = generateSecretKey(), C = generateSecretKey();
const now = Math.floor(Date.now() / 1000);
const note = (sk, content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, sk);
const repost = (sk, inner, ago) => finalizeEvent({ kind: 6, created_at: now - ago, tags: [['e', inner.id], ['p', inner.pubkey]], content: JSON.stringify(inner) }, sk);
const READ = note(A, 'the one already read, about weekend projects', 3000);
const UNREAD = note(C, 'never seen before, a fresh find', 5000);
const seeded = [
  ...Array.from({ length: 3 }, (_, i) => note(A, 'newer filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(4), 600 + i * 60)),
  READ,
  ...Array.from({ length: 8 }, (_, i) => note(B, 'older filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(4), 3600 + i * 60)),
];
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: (req) => {
  const path = new URL(req.url).pathname;
  if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
  return 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();
// The relays, in the page: they hold the seeded posts (plus whatever the
// test adds to localStorage's __relayExtra, for after a reload), answer a REQ
// by kinds/authors/until/limit, and __push() delivers a new event live to
// every open subscription it matches.
await page.evaluateOnNewDocument((seed) => {
  let extra = [];
  try { extra = JSON.parse(localStorage.getItem('__relayExtra') || '[]'); } catch {}
  const evs = [...seed, ...extra];
  const match = (f, e, live) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id))
    && (live || ((!f.until || e.created_at <= f.until) && (!f.since || e.created_at >= f.since))) && !Object.keys(f).some((k) => k.startsWith('#'));
  const subs = new Set();
  class FakeWS {
    constructor(url) { this.url = String(url); this.readyState = 0; setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 5); }
    reply(x) { setTimeout(() => this.readyState === 1 && this.onmessage && this.onmessage({ data: JSON.stringify(x) }), 5); }
    send(raw) {
      const m = JSON.parse(raw);
      if (m[0] === 'REQ') {
        const filters = m.slice(2).filter((f) => !f.cache);
        subs.add({ ws: this, id: m[1], filters });
        for (const f of filters) {
          const hits = evs.filter((e) => match(f, e)).sort((a, b) => b.created_at - a.created_at);
          for (const e of f.limit ? hits.slice(0, f.limit) : hits) this.reply(['EVENT', m[1], e]);
        }
        this.reply(['EOSE', m[1]]);
      } else if (m[0] === 'CLOSE') { for (const s of subs) if (s.ws === this && s.id === m[1]) subs.delete(s); }
      else if (m[0] === 'EVENT') this.reply(['OK', m[1].id, true, '']);
      else if (m[0] === 'COUNT') this.reply(['COUNT', m[1], { count: 0 }]);
    }
    close() { this.readyState = 3; for (const s of subs) if (s.ws === this) subs.delete(s); this.onclose && this.onclose({ code: 1000 }); }
    addEventListener(t, fn) { this['on' + t] = fn; }
    removeEventListener() {}
  }
  FakeWS.CONNECTING = 0; FakeWS.OPEN = 1; FakeWS.CLOSING = 2; FakeWS.CLOSED = 3;
  window.WebSocket = FakeWS;
  window.__push = (e) => { let n = 0; for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match(f, e, true))) { s.ws.reply(['EVENT', s.id, e]); n++; } return n; };
}, seeded);
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 waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
const rows = (w) => page.evaluate((x) => [...document.querySelectorAll('.notes-feed > [data-key]')].filter((n) => n.textContent.includes(x)).map((n) => ({ top: Math.round(n.getBoundingClientRect().top + scrollY), reposted: !!n.querySelector('.note-reposted') })), w);
const seenIds = (base) => page.evaluate((k) => { try { return JSON.parse(localStorage.getItem('coinos-seen:' + k) || '[]').map((x) => x[0]); } catch { return []; } }, base);
// take in whatever is waiting above (the new-posts pill), and go to the top
const takeNew = async () => {
  await sleep(1500);
  await page.evaluate(() => document.querySelector('.feed-new-pill')?.click());
  await sleep(800);
  await page.evaluate(() => window.scrollTo(0, 0));
  await sleep(500);
};
const openFeed = async () => {
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  return waitFor(() => document.querySelectorAll('.notes-feed > [data-key]').length >= 6, null, 20000);
};

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'); localStorage.removeItem('__relayExtra'); });
  await page.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await click('create a new wallet'); await sleep(600);
  await page.waitForSelector('.words .w .t');
  const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
  await click('skip verification');
  await waitText('receive', 20000);
  const base = cacheKeyFor(mn + '\n');
  await page.evaluate(([k, pks]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: pks.map((p) => ['p', p]), c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, [getPublicKey(A), getPublicKey(B)]]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  check('the feed opens', await openFeed());
  check('the post to read is in it', await waitFor(() => [...document.querySelectorAll('.notes-feed > [data-key]')].some((n) => n.textContent.includes('the one already read')), null, 15000));

  console.log('\n[a row held on screen for a second is read]');
  await page.evaluate(() => [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('the one already read')).scrollIntoView({ block: 'center' }));
  await sleep(400);
  check('not read at a glance', !(await seenIds(base)).includes(READ.id));
  await sleep(2200);
  check('read after a second in view', (await seenIds(base)).includes(READ.id), (await seenIds(base)).length + ' remembered');
  const beforeTop = (await rows('the one already read'))[0]?.top;

  console.log('\n[a boost, live: the read post stays put, an unread one lands]');
  await page.evaluate(() => window.scrollTo(0, 0)); await sleep(400);
  const r1 = repost(B, READ, 30), r2 = repost(B, UNREAD, 20);
  const pushed = await page.evaluate(([a, b]) => window.__push(a) + window.__push(b), [r1, r2]);
  check('the boosts reach the feed\'s subscriptions', pushed > 0, pushed + ' deliveries');
  await takeNew();
  check('the boost of an unread post lands', await waitFor(() => [...document.querySelectorAll('.notes-feed > [data-key]')].some((n) => n.textContent.includes('never seen before') && n.querySelector('.note-reposted')), null, 6000));
  let r = await rows('the one already read');
  check('the read post is shown once', r.length === 1, JSON.stringify(r));
  check('…as itself, not under the boost', r.length === 1 && !r[0].reposted);

  console.log('\n[after a reload, a different boost of it stays out too]');
  const r3 = repost(B, READ, 10);
  await page.evaluate((x) => localStorage.setItem('__relayExtra', JSON.stringify(x)), [r1, r2, r3]);
  await page.evaluate(() => { localStorage.removeItem(Object.keys(localStorage).find((k) => k.endsWith(':feedNotes')) || '_'); }); // a cold start: nothing cached
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  check('the feed opens again', await openFeed());
  await takeNew();
  await waitFor(() => [...document.querySelectorAll('.notes-feed > [data-key]')].some((n) => n.textContent.includes('never seen before')), null, 8000);
  r = await rows('the one already read');
  check('the read post is not boosted back to the top', r.every((x) => !x.reposted), JSON.stringify(r));
  check('…and it is still in the feed, in its place', r.length === 1 && Math.abs(r[0].top - beforeTop) < 400, JSON.stringify(r) + ' was ' + beforeTop);
  check('the unread post\'s boost is still there', (await rows('never seen before')).length === 1);
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ a read post isn\'t boosted back to the top' : '\n❌ failed');
process.exit(ok ? 0 : 1);