aboutsummaryrefslogtreecommitdiffstats
path: root/tools/feed-stability-test.js
blob: 6befa1e8d173ef35fde6d50d21e4bc8a99210047 (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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
// Offline browser regression: cached rows must arrive whole and stay still.
// Run: bun tools/feed-stability-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 { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { noteEncode, npubEncode } from 'nostr-tools/nip19';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (name, condition, detail = '') => {
  console.log(`${condition ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
  if (!condition) ok = false;
};
const sk = generateSecretKey(), pk = getPublicKey(sk);
const otherKey = generateSecretKey(), other = getPublicKey(otherKey);
const now = Math.floor(Date.now() / 1000);
const event = (content, at, key = sk, kind = 1) => finalizeEvent({ kind, content, created_at: at, tags: [] }, key);
const origin = 'http://localhost:5298';
const quote = event('Quoted content ' + origin + '/quote.png', now - 2000, otherKey);
const profile = event(JSON.stringify({ name: 'Mentioned Person', picture: origin + '/quote-avatar.png' }), now, otherKey, 0);
const seeded = Array.from({ length: 45 }, (_, i) => event('Cached post ' + i + '\nEnough text to make each post a comfortable reading target.', now - 600 - i * 60));
seeded[1] = event('With a quote nostr:' + noteEncode(quote.id) + ' and @' + npubEncode(other), now - 660);
seeded[3] = event('Unavailable picture ' + origin + '/late.png', now - 780);
seeded[8] = event('Five pictures ' + Array.from({ length: 5 }, (_, i) => origin + '/gallery' + i + '.png').join(' '), now - 1080);
seeded[12] = event('Next page picture ' + origin + '/next.png', now - 1320);
const requested = new Map();
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5298, fetch: async (req) => {
  const path = new URL(req.url).pathname;
  if (path === '/') return new Response(html, { headers: { 'content-type': 'text/html' } });
  if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
  requested.set(path, (requested.get(path) || 0) + 1);
  const delay = path === '/avatar.png' ? 3000 : path === '/gallery4.png' || path === '/next.png' ? 3500 : path === '/late.png' ? 10000 : 150;
  await sleep(delay);
  return new Response('<svg xmlns="http://www.w3.org/2000/svg" width="160" height="100"><rect width="160" height="100" fill="teal"/></svg>', {
    headers: { 'content-type': 'image/svg+xml', 'cache-control': 'public, max-age=3600' },
  });
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
await page.setViewport({ width: 390, height: 844 });
await page.evaluateOnNewDocument((fixtures) => {
  const sockets = [];
  class FakeSocket extends EventTarget {
    constructor(url) {
      super(); this.url = url; this.readyState = 0; this.subs = new Map(); sockets.push(this);
      setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0);
    }
    emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); }
    message(data) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(data) })); }
    send(raw) {
      let m; try { m = JSON.parse(raw); } catch { return; }
      if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
      if (m[0] !== 'REQ') return;
      const [_, id, filter] = m;
      (window.__feedRequests ||= []).push(filter);
      this.subs.set(id, filter);
      let events = [];
      if (filter.kinds?.includes(0) && filter.authors?.includes(fixtures.profile.pubkey)) events = [fixtures.profile];
      if (filter.ids?.includes(fixtures.quote.id)) events = [fixtures.quote];
      if (filter.kinds?.includes(1) && !filter.since && !filter.until) events = window.__catchup || [];
      setTimeout(() => {
        if (!this.subs.has(id)) return;
        for (const e of events) this.message(['EVENT', id, e]);
        this.message(['EOSE', id]);
      }, events.length ? 700 : 20);
    }
    close() { this.readyState = 3; this.emit('close', new Event('close')); }
  }
  Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
  window.WebSocket = FakeSocket;
  window.__inject = (e) => {
    let count = 0;
    for (const ws of sockets) for (const [id, f] of ws.subs) {
      if (ws.readyState === 1 && f.kinds?.includes(1) && f.since) { ws.message(['EVENT', id, e]); count++; }
    }
    return count;
  };
}, { profile, quote });
const click = (text) => page.evaluate((text) => [...document.querySelectorAll('button')].find((e) => e.textContent.toLowerCase().includes(text))?.click(), text);
const row = (id) => `.notes-feed > [data-key="${id}"]`;
// the Feed is a tab in the bottom nav (it was a row on the chat home)
const feedTab = () => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed');
const snapshot = () => page.evaluate(() => [...document.querySelectorAll('.notes-feed > [data-zap-post]')].map((n) => {
  const b = n.getBoundingClientRect();
  return { id: n.dataset.key, top: b.top, height: b.height, text: n.innerText, avatar: n.querySelector('.note-avatar')?.getAttribute('style') };
}));
try {
  await page.goto(origin);
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload(); await sleep(300);
  await click('create a new wallet'); await sleep(400);
  await click('to import');
  await page.waitForSelector('textarea');
  const mnemonic = generateMnemonic(wordlist);
  await page.type('textarea', mnemonic);
  await click('open wallet');
  await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'));
  const base = cacheKeyFor(mnemonic + '\n');
  await page.evaluate(([base, pk, seeded, origin]) => {
    localStorage.setItem(base + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(base + ':feedNotes', JSON.stringify(seeded));
    localStorage.setItem(base + ':profiles', JSON.stringify({ [pk]: { name: 'Cached Author', picture: origin + '/avatar.png', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + base, 'following'); // a wallet with no feed remembered opens on Popular
  }, [base, pk, seeded, origin]);
  await page.reload();
  await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'));
  await sleep(2000);
  check('a small feed query starts before opening the feed', await page.evaluate(() =>
    !document.querySelector('.notes-feed') && (window.__feedRequests || []).some((f) => f.kinds?.includes(1) && !f.kinds.some((k) => k !== 1 && k !== 1068) && f.limit === 10)));
  await page.evaluate((tab) => eval(tab)()?.click(), feedTab.toString());
  await sleep(1000);
  // Cached posts paint at once — never a spinner over posts we hold — and
  // settle into their prepared presentation once faces and pictures are in.
  check('cached rows paint at once, before their slow avatar has loaded', !!(await page.$('.notes-feed[data-booting] > .row')));
  await page.waitForSelector('.notes-feed:not([data-booting]) > .row', { timeout: 15000 });
  await page.waitForSelector(row(seeded[0].id), { timeout: 15000 });
  check('quoted note and mention resolved once settled', await page.$eval(row(seeded[1].id), (n) => n.innerText.includes('Quoted content') && n.innerText.includes('@Mentioned Person')));
  check('all five gallery images decoded once settled', await page.$eval(row(seeded[8].id), (n) => n.querySelectorAll('.note-img').length === 5 && [...n.querySelectorAll('.note-img')].every((i) => i.complete && i.naturalWidth > 0)));
  check('the next page was prefetched before scrolling', requested.has('/next.png'));
  check('an unavailable image remains a usable link', await page.$eval(row(seeded[3].id), (n) => !n.querySelector('img.note-img') && !!n.querySelector('a[href$="/late.png"]')));
  const initial = await snapshot();
  await sleep(3000);
  const settledRows = new Map((await snapshot()).map((r) => [r.id, r]));
  check('late image responses do not change admitted rows', initial.every((r) => JSON.stringify(r) === JSON.stringify(settledRows.get(r.id))));

  // With the header visible, a new post goes in above the first post, which
  // is held where it is (the page grows upward; the header scrolls off by
  // what went in above it).
  const firstBefore = await page.evaluate(() => { const r = document.querySelector('.notes-feed > [data-zap-post]'); return { id: r.dataset.key, top: r.getBoundingClientRect().top }; });
  const fresh = event('A new live post', now + 1);
  check('a live subscription received the event', await page.evaluate((e) => window.__inject(e), fresh) > 0);
  await page.waitForSelector(row(fresh.id), { timeout: 5000 }).catch(() => {});
  check('live arrival at the top holds the first post still', await page.evaluate((b) => {
    const r = document.querySelector('.notes-feed > [data-key="' + b.id + '"]');
    return !!r && Math.abs(r.getBoundingClientRect().top - b.top) <= 1;
  }, firstBefore));
  check('...and goes in above it', await page.evaluate(([f, id]) => {
    const a = document.querySelector('.notes-feed > [data-key="' + f + '"]'), b = document.querySelector('.notes-feed > [data-key="' + id + '"]');
    return !!a && !!b && a.getBoundingClientRect().top < b.getBoundingClientRect().top;
  }, [fresh.id, firstBefore.id]));

  // Once the reader has scrolled the header away, preserve every visible row
  // on every animation frame, including the first frame of the prepend.
  await page.evaluate(() => {
    const first = [...document.querySelectorAll('.notes-feed > [data-zap-post]')][1];
    scrollBy(0, Math.ceil(first.getBoundingClientRect().top));
  });
  await sleep(500);
  await page.evaluate(() => {
    window.__drift = [];
    window.__held = [...document.querySelectorAll('.notes-feed > [data-zap-post]')].filter((r) => {
      const b = r.getBoundingClientRect(); return b.bottom > 0 && b.top < innerHeight;
    }).map((r) => [r, r.getBoundingClientRect().top]);
    window.__sampling = true;
    const frame = () => {
      for (const [r, y] of window.__held) if (!r.isConnected || Math.abs(r.getBoundingClientRect().top - y) > 1) window.__drift.push([r.dataset.key, y, r.getBoundingClientRect().top]);
      if (window.__sampling) requestAnimationFrame(frame);
    }; requestAnimationFrame(frame);
  });
  const fresh2 = event('Another live post', now + 2);
  check('a second live post is received', await page.evaluate((e) => window.__inject(e), fresh2) > 0);
  await page.waitForSelector(row(fresh2.id));
  await sleep(500);
  const drift = await page.evaluate(() => { window.__sampling = false; return window.__drift; });
  check('offscreen arrival never moves a visible row, even for one frame', !drift.length, JSON.stringify(drift.slice(0, 3)));
  check('new rows do not animate their height', await page.$eval(row(fresh2.id), (n) => !n.getAnimations().length));

  const gap = event('Gap inside visible posts', now - 630);
  await page.evaluate((e) => {
    window.__catchup = [e];
    Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
    document.dispatchEvent(new Event('visibilitychange'));
  }, gap);
  await sleep(5200);
  await page.evaluate(() => {
    Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
    document.dispatchEvent(new Event('visibilitychange'));
  });
  await sleep(1500);
  check('a gap fill does not insert between visible posts', !(await page.$(row(gap.id))));
  await page.evaluate(() => scrollTo(0, 2000));
  await page.waitForSelector(row(gap.id));
  check('the gap fill is admitted once offscreen', !!(await page.$(row(gap.id))));

  await page.$eval(row(seeded[18].id), (n) => n.scrollIntoView());
  await page.waitForSelector(row(seeded[12].id), { timeout: 10000 });
  check('older posts reveal with decoded pictures', await page.$eval(row(seeded[12].id), (n) => { const i = n.querySelector('.note-img'); return !!i && i.complete && i.naturalWidth > 0; }));

  const before = (await snapshot()).find((r) => r.top + r.height > 0);
  await page.evaluate((evs) => {
    window.__catchup = evs;
    Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
    document.dispatchEvent(new Event('visibilitychange'));
  }, Array.from({ length: 25 }, (_, i) => event('Large catch-up ' + i, now + 30 + i)));
  await sleep(5200);
  await page.evaluate(() => {
    Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
    document.dispatchEvent(new Event('visibilitychange'));
  });
  // the new posts go in above the reader — inside the window's top spacer,
  // not the DOM — so what shows is the notice counting them
  try { await page.waitForFunction(() => parseInt((document.querySelector('.feed-new-pill')?.textContent || '0').replace(/\D/g, ''), 10) >= 25, { timeout: 15000 }); }
  catch (e) { console.log('  catch-up did not surface:', JSON.stringify(await page.evaluate(() => ({ pill: document.querySelector('.feed-new-pill')?.textContent || null, rows: document.querySelectorAll('.notes-feed > .row').length, h: document.documentElement.scrollHeight, y: scrollY, top: document.querySelector('[data-key="win-top"]')?.offsetHeight || 0, first: document.querySelector('.notes-feed > .row')?.innerText.slice(0, 40), catchupLeft: (window.__catchup || []).length, reqs: (window.__feedRequests || []).length }))), errors.slice(0, 3)); }
  const after = (await snapshot()).find((r) => r.id === before.id);
  check('a large catch-up preserves the reading position', !!after && Math.abs(after.top - before.top) <= 1, JSON.stringify({ before: before.top, after: after?.top }));
  // Start on the wallet and let its small background warm-up finish. The
  // first feed tap must paint rows synchronously, without a loading frame.
  await page.evaluate(([base, notes]) => {
    localStorage.setItem(base + ':feedNotes', JSON.stringify(notes));
    const nav = { ...history.state.nav, screen: 'wallet', tab: 'history', chatOpen: false,
      noteThread: null, profilePk: null, msgView: 'home' };
    history.replaceState({ nav, i: 0 }, '', '/');
  }, [base, seeded.filter((_, i) => ![1, 3, 8, 12].includes(i)).slice(0, 10)]);
  await page.reload();
  await sleep(5500);
  await page.waitForSelector('.app-nav-button');
  check('first opening after background preparation has rows and no spinner', await page.evaluate((tab) => {
    eval(tab)()?.click();
    return !!document.querySelector('.notes-feed > .row') && !document.querySelector('.chat-page .spinner');
  }, feedTab.toString()));
  check('no browser errors', !errors.length, errors.join(' | '));
} finally { await browser.close(); server.stop(true); }
process.exit(ok ? 0 : 1);