aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/avatar-thumb-test.js4
-rw-r--r--tools/feature-registry-check.js1
-rw-r--r--tools/feed-live-test.js57
-rw-r--r--tools/feed-stability-test.js191
-rw-r--r--tools/feed-window-test.js55
-rw-r--r--tools/thread-cache-test.js120
6 files changed, 407 insertions, 21 deletions
diff --git a/tools/avatar-thumb-test.js b/tools/avatar-thumb-test.js
index 413a92b..f70f6b3 100644
--- a/tools/avatar-thumb-test.js
+++ b/tools/avatar-thumb-test.js
@@ -182,7 +182,7 @@ try {
const i = document.querySelector('.header-avatar img.punk') || document.querySelector('img.punk');
return i ? i.getAttribute('src') : '';
});
- check('the default face is the small punk', /^punks-sm\//.test(punkSrc), punkSrc);
+ check('the default face is the small punk', /^\/punks-sm\//.test(punkSrc), punkSrc);
// And someone we have never cached at all — the case that used to sit as a
// blank circle until a relay answered — is drawn from their pubkey alone.
@@ -195,7 +195,7 @@ try {
const i = document.querySelector('.header-avatar img.punk') || document.querySelector('img.punk');
return i ? i.getAttribute('src') : '';
});
- check('an uncached face is drawn straight away, not left blank', /^punks-sm\//.test(strangerSrc), strangerSrc || 'blank circle');
+ check('an uncached face is drawn straight away, not left blank', /^\/punks-sm\//.test(strangerSrc), strangerSrc || 'blank circle');
// A picture behind a redirect, loaded plainly first (as the avatar always
// is) and only then thumbnailed.
diff --git a/tools/feature-registry-check.js b/tools/feature-registry-check.js
index 0931156..5d0adc3 100644
--- a/tools/feature-registry-check.js
+++ b/tools/feature-registry-check.js
@@ -14,6 +14,7 @@ import { featureIndexSource, enabledFeatures } from '../build.js';
const strip = (s) => s
.replace(/\/\/[^\n]*/g, '') // line comments
.replace(/\s+/g, ' ') // whitespace runs
+ .replace(/,\s*([}\]])/g, '$1') // optional trailing commas
.replace(/, /g, ',').replace(/ ,/g, ',')
.trim();
diff --git a/tools/feed-live-test.js b/tools/feed-live-test.js
index c4807f1..7732045 100644
--- a/tools/feed-live-test.js
+++ b/tools/feed-live-test.js
@@ -6,8 +6,8 @@
// catches up by itself now.
//
// And a post that lands while you are reading halfway down must not shove the
-// page under your thumb. It waits behind a pill that says how many, and the
-// tap that shows them takes you up to them.
+// page under your thumb. Prepared posts arrive above the viewport, and the
+// notice takes you up to them when tapped.
//
// Run: bun tools/feed-live-test.js
import puppeteer from 'puppeteer-core';
@@ -33,7 +33,9 @@ const html = await buildHtml({ minify: true, pwa: false });
// a picture that takes its time, so a post showing it must have waited
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
const server = Bun.serve({ port: 5296, fetch: async (req) => {
- if (new URL(req.url).pathname === '/slow.png') {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ if (path === '/slow.png') {
await sleep(700);
return new Response(PNG, { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } });
}
@@ -102,6 +104,7 @@ try {
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));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Test Author', t: Date.now() } }));
}, [base, AUTHOR, seeded]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
@@ -126,8 +129,7 @@ try {
const delivered = await page.evaluate((ev) => window.__inject(ev), fresh);
check('...a post really arrives on a live subscription', delivered > 0, delivered + ' socket(s)');
// A post waits at the door for its author's face and its pictures (or the
- // two-second deadline, for a face nobody has) before it goes in — so give
- // it that long, and no longer.
+ // loading deadline) before it goes in. This author is cached locally.
await sleep(2600);
const after = await page.evaluate((text) => {
const r = [...document.querySelectorAll('.notes-feed > .row')].find((n) => n.innerText.includes(text));
@@ -157,10 +159,13 @@ try {
await sleep(900);
check('...that reaching the top clears by itself', !(await page.$('.feed-new-pill')));
- console.log('\n[a post arrives whole, at the top]');
- // At the top of the feed a live post opens itself — no pill. It waits at
- // the door for its picture first, and the first time the row exists its
- // image is already decoded. A row used to paint first and fill in after.
+ console.log('\n[a post arrives whole, above the viewport]');
+ // Move the first post to the viewport edge so a prepend is offscreen.
+ await page.evaluate(() => {
+ const first = document.querySelector('.notes-feed > .row');
+ scrollBy(0, Math.ceil(first.getBoundingClientRect().top));
+ });
+ // The image is decoded before the row is admitted, with no height animation.
await page.evaluate(() => {
window.__firstSight = null;
const feed = document.querySelector('.notes-feed');
@@ -180,11 +185,11 @@ try {
});
const t0 = await page.evaluate(() => performance.now());
await page.evaluate((ev) => window.__inject(ev), signed('PICTURE POST http://localhost:5296/slow.png?' + Date.now(), 0));
- check('...sitting at the top', (await page.evaluate(() => window.scrollY)) < 120);
+ const heldPictureAnchor = await page.evaluate(() => { const r = document.querySelector('.notes-feed > .row'); return { id: r.dataset.key, top: r.getBoundingClientRect().top }; });
await page.waitForFunction(() => !!window.__firstSight, { timeout: 4000 }).catch(() => {});
- await sleep(120); // a hair in: the animation has been started and not finished
+ await sleep(120);
const sight = await page.evaluate(() => window.__firstSight);
- check('the post let itself in — no pill', !!sight && sight.img && !(await page.$('.feed-new-pill')), JSON.stringify(sight));
+ check('the prepared post arrived above the reader', !!sight && sight.img && !!(await page.$('.feed-new-pill')), JSON.stringify(sight));
check('...having waited for the slow host', !!sight && sight.at - t0 >= 700, sight ? Math.round(sight.at - t0) + 'ms' : '');
const mid = await page.evaluate(() => {
const row = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /PICTURE POST/.test(n.innerText || ''));
@@ -195,8 +200,12 @@ try {
const row = [...document.querySelectorAll('.notes-feed > .row')].find((n) => /PICTURE POST/.test(n.innerText || ''));
return row ? { anims: row.getAnimations().length, h: row.getBoundingClientRect().height, overflow: row.style.overflow } : null;
});
- check('...and it opened rather than appeared', !!mid && !!settled && mid.anims > 0 && mid.h < settled.h
+ check('...without animating its height', !!mid && !!settled && mid.anims === 0 && mid.h === settled.h
&& settled.anims === 0 && settled.h > 0 && !settled.overflow, JSON.stringify({ mid, settled }));
+ check('...and the reading position is preserved', await page.evaluate(({ id, top }) => {
+ const row = document.querySelector('.notes-feed > [data-key="' + id + '"]');
+ return row && Math.abs(row.getBoundingClientRect().top - top) <= 1;
+ }, heldPictureAnchor));
// decoded before the row is inserted; the fresh <img> node reports it a
// task later, so the row's first frames are what is checked here
check('...and its picture was already decoded when the row appeared', !!sight && (sight.ready || sight.readyMs <= 60), JSON.stringify(sight));
@@ -235,13 +244,16 @@ try {
await page.evaluate(() => window.scrollTo(0, 900));
await sleep(500);
const held = await anchor();
+ // the notice keeps counting while the reader stays put: what matters is that it grew by three
+ const pillN = () => page.evaluate(() => parseInt((document.querySelector('.feed-new-pill')?.textContent || '0').replace(/\D/g, ''), 10) || 0);
+ const pillBefore = await pillN();
const r1 = await away(() => [signed('CATCH-UP 1', 0), signed('CATCH-UP 2', 1), signed('CATCH-UP 3', 2),
signed('an old post that was not in the cache', 5000), signed('another old one', 5100)]);
check('coming back re-asks the relays without being told', r1.reqsAfter > r1.reqsBefore, `${r1.reqsBefore} → ${r1.reqsAfter} kind-1 requests`);
const a1 = await rowAt(held.text);
const feedText = () => page.evaluate(() => document.querySelector('.notes-feed')?.innerText || '');
check('a few new posts go straight in', /CATCH-UP 1/.test(await feedText()));
- check('...and the pill says how many are above you', /3 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || '')));
+ check('...and the pill says how many more are above you', (await pillN()) === pillBefore + 3, pillBefore + ' → ' + (await pillN()));
check('...and the post you were reading has not moved', !!held && !!a1 && a1.text === held.text && Math.abs(a1.top - held.top) <= 2,
JSON.stringify({ held, now: a1 }));
// they sort below everything cached: page down to them
@@ -256,19 +268,26 @@ try {
const wasTop = await anchor();
await away(() => [signed('AT-THE-TOP A', 0), signed('AT-THE-TOP B', 1)]);
const a2 = await rowAt(wasTop.text);
- check('the post you had at the top stays under your eyes', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y > 0,
+ check('the header and first post stay in place', !!wasTop && !!a2 && a2.text === wasTop.text && Math.abs(a2.top - wasTop.top) <= 2 && a2.y === wasTop.y,
JSON.stringify({ was: wasTop, now: a2 }));
- check('...with the new ones above it, newest first, and a pill saying so', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || '')));
+ check('...new posts wait while the header is visible', !/AT-THE-TOP A/.test(await topRow()));
+ await page.evaluate(() => {
+ const first = document.querySelector('.notes-feed > .row');
+ scrollBy(0, Math.ceil(first.getBoundingClientRect().top));
+ });
+ await sleep(600);
+ check('...then arrive offscreen above it, with a notice', /AT-THE-TOP A/.test(await topRow()) && /2 new posts/i.test(await page.evaluate(() => document.querySelector('.feed-new-pill')?.textContent || '')));
console.log('\n[coming back after a long time away]');
await page.evaluate(() => window.scrollTo(0, 900));
await sleep(400);
+ const heldLong = await anchor();
// all in the same second — a post per second back would reach past the
// top of the page after the thirteen seconds the two waits above took
await away(() => Array.from({ length: 25 }, (_, i) => signed('LONG AWAY ' + (i + 1), 0)));
- const a3 = await anchor();
- check('many new posts: the feed goes to the top', !!a3 && a3.y < 120, JSON.stringify(a3));
- check('...at the newest post, with no pill', /LONG AWAY 1\b/.test(await topRow()) && !(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40));
+ const a3 = await rowAt(heldLong.text);
+ check('many new posts still preserve the reading position', !!a3 && Math.abs(a3.top - heldLong.top) <= 2, JSON.stringify({ heldLong, a3 }));
+ check('...with the newest posts above and a notice', /LONG AWAY 1\b/.test(await topRow()) && !!(await page.$('.feed-new-pill')), (await topRow()).slice(0, 40));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
diff --git a/tools/feed-stability-test.js b/tools/feed-stability-test.js
new file mode 100644
index 0000000..1fdc5ba
--- /dev/null
+++ b/tools/feed-stability-test.js
@@ -0,0 +1,191 @@
+// Offline browser regression: cached rows must arrive whole and stay still.
+// Run: bun tools/feed-stability-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 { 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[24] = event('Next page picture ' + origin + '/next.png', now - 2040);
+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;
+ 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}"]`;
+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() } }));
+ }, [base, pk, seeded, origin]);
+ await page.reload();
+ await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'));
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || ''))?.click());
+ await page.waitForSelector('.item');
+ await page.evaluate(() => [...document.querySelectorAll('.item')].find((e) => /feed/i.test(e.textContent))?.click());
+ await sleep(1000);
+ check('cached rows wait for their slow avatar', !(await page.$('.notes-feed > .row')));
+ await page.waitForSelector(row(seeded[0].id), { timeout: 15000 });
+ check('quoted note and mention resolved before admission', await page.$eval(row(seeded[1].id), (n) => n.innerText.includes('Quoted content') && n.innerText.includes('@Mentioned Person')));
+ check('all five gallery images decoded before admission', 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 prepend must wait: anchoring only the first
+ // post would otherwise scroll the header offscreen.
+ const headerBefore = await page.evaluate(() => ({ y: scrollY, text: document.querySelector('.chat-page').innerText }));
+ 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 sleep(1000);
+ check('live arrival leaves the visible header and posts untouched', await page.evaluate((before) =>
+ scrollY === before.y && document.querySelector('.chat-page').innerText === before.text, headerBefore));
+ check('new post waits until it can be inserted offscreen', !(await page.$(row(fresh.id))));
+
+ // Once the reader scrolls the header away, preserve every visible row on
+ // every animation frame, including the first frame of the prepend.
+ await page.evaluate(() => {
+ const first = document.querySelector('.notes-feed > [data-zap-post]');
+ scrollBy(0, Math.ceil(first.getBoundingClientRect().top));
+ 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);
+ });
+ await page.waitForSelector(row(fresh.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(fresh.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[24].id), { timeout: 10000 });
+ check('older posts reveal with decoded pictures', await page.$eval(row(seeded[24].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'));
+ });
+ await page.waitForFunction(() => document.querySelector('.notes-feed')?.innerText.includes('Large catch-up 24'), { timeout: 15000 });
+ 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 }));
+ check('no browser errors', !errors.length, errors.join(' | '));
+} finally { await browser.close(); server.stop(true); }
+process.exit(ok ? 0 : 1);
diff --git a/tools/feed-window-test.js b/tools/feed-window-test.js
new file mode 100644
index 0000000..64e8364
--- /dev/null
+++ b/tools/feed-window-test.js
@@ -0,0 +1,55 @@
+// Geometry/state regressions that don't require browser or network access.
+// Run: bun tools/feed-window-test.js
+import assert from 'node:assert/strict';
+import { mergeFeedWindow } from '../src/feed-window.js';
+const note = (id, at) => ({ id, created_at: at });
+const notes = [note('a', 100), note('b', 90), note('c', 80), note('d', 70)];
+const rows = (top) => notes.map((e, i) => ({ id: e.id, top: top + i * 200, bottom: top + (i + 1) * 200 }));
+const above = note('above', 110), gap = note('gap', 95), below = note('below', 60);
+const check = (name, fn) => { fn(); console.log('✓ ' + name); };
+check('header-visible arrivals wait without changing the displayed window', () => {
+ const r = mergeFeedWindow(notes, 4, [above, gap], { rows: rows(180), height: 844 });
+ assert.equal(r.notes, notes);
+ assert.equal(r.shown, 4);
+ assert.deepEqual(r.deferred, [above, gap]);
+});
+check('offscreen prepends preserve every displayed post; visible gap fills wait', () => {
+ const r = mergeFeedWindow(notes, 4, [above, gap, below], { rows: rows(-50), height: 844 });
+ assert.deepEqual(r.added, [above, below]);
+ assert.deepEqual(r.deferred, [gap]);
+ assert.deepEqual(r.notes.slice(0, r.shown).map((e) => e.id), ['above', 'a', 'b', 'c', 'd']);
+});
+check('a deferred gap becomes available once it is above the viewport', () => {
+ const r = mergeFeedWindow(notes, 4, [gap], { rows: rows(-450), height: 844 });
+ assert.deepEqual(r.deferred, []);
+ assert.deepEqual(r.notes.map((e) => e.id), ['a', 'gap', 'b', 'c', 'd']);
+});
+check('hidden posts cannot allow an insertion between visible posts', () => {
+ const r = mergeFeedWindow([notes[0], note('hidden', 97), ...notes.slice(1)], 4, [note('gap', 98)], { rows: rows(-50), height: 844 });
+ assert.equal(r.added.length, 0);
+ assert.equal(r.deferred.length, 1);
+});
+check('equal timestamps retain their order and obey the visible-gap rule', () => {
+ const r = mergeFeedWindow(notes, 4, [note('equal', 100)], { rows: rows(-50), height: 844 });
+ assert.equal(r.added.length, 0);
+ assert.equal(r.deferred[0].id, 'equal');
+});
+check('large catch-ups retain the reader at the cache boundary', () => {
+ const old = Array.from({ length: 200 }, (_, i) => note('old' + i, 1000 - i));
+ const news = Array.from({ length: 80 }, (_, i) => note('new' + i, 2000 - i));
+ const r = mergeFeedWindow(old, 200, news);
+ assert.equal(r.shown, 280);
+ assert.equal(r.notes.at(-1).id, 'old199');
+});
+check('older-page requests still advance beyond the cache boundary', () => {
+ const old = Array.from({ length: 200 }, (_, i) => note('old' + i, 1000 - i));
+ const older = Array.from({ length: 40 }, (_, i) => note('older' + i, 500 - i));
+ const r = mergeFeedWindow(old, 200, older);
+ assert.equal(r.notes.length, 220);
+ assert.equal(r.notes[200].id, 'older0');
+});
+check('duplicate arrivals cannot create repeated rows', () => {
+ const r = mergeFeedWindow(notes, 4, [notes[0], above, above]);
+ assert.equal(r.notes.length, 5);
+ assert.deepEqual(r.added, [above]);
+});
diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js
new file mode 100644
index 0000000..fd3f5c8
--- /dev/null
+++ b/tools/thread-cache-test.js
@@ -0,0 +1,120 @@
+// Persistent thread context and the actual thread loader, with relay queries
+// controlled in-process. No browser, network access, or publishing required.
+// Run: bun tools/thread-cache-test.js
+import assert from 'node:assert/strict';
+import { rm } from 'node:fs/promises';
+import { createThreadStore } from '../src/thread-cache.js';
+const memory = () => {
+ const values = new Map();
+ return { getItem: (k) => values.get(k) ?? null, setItem: (k, v) => values.set(k, v) };
+};
+const storage = memory();
+globalThis.localStorage = storage;
+const id = (n) => n.toString(16).padStart(64, '0');
+const note = (n, tags = []) => ({ id: id(n), pubkey: id(900), kind: 1, content: 'Post ' + n, created_at: n, tags });
+const root = note(1), parent = note(2, [['e', root.id, '', 'reply']]), reply = note(3, [['e', parent.id, '', 'reply']]);
+const check = (name, fn) => { fn(); console.log('✓ ' + name); };
+const store = createThreadStore(storage);
+check('a fresh store restores full content and tags by any viewed event', () => {
+ const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] };
+ store.save({ root, replies: [parent, long] }, long.id);
+ const restored = createThreadStore(storage).find(long.id);
+ assert.deepEqual(restored, { rootId: root.id, root, replies: [long, parent] });
+ assert.equal(store.find(parent.id).root.id, root.id);
+});
+check('a large thread retains the selected reply and its ancestors', () => {
+ const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']]));
+ store.save({ root, replies: [...siblings, parent, reply] }, reply.id);
+ const saved = store.find(reply.id);
+ assert(saved.replies.some((e) => e.id === parent.id));
+ assert(saved.replies.length < 160);
+});
+check('the thread cache is bounded and recent conversations win', () => {
+ for (let i = 10; i < 30; i++) store.save({ root: note(i), replies: [] }, id(i));
+ assert.equal(store.find(root.id), null);
+ assert.equal(store.find(id(10)), null);
+ assert(store.find(id(29)));
+});
+check('corrupt or unavailable storage does not break opening a thread', () => {
+ assert.equal(createThreadStore({ getItem: () => '{' }).find(root.id), null);
+ assert.equal(createThreadStore({ getItem: () => JSON.stringify([{ root: { ...root, tags: null }, replies: [] }]) }).find(root.id), null);
+ assert.doesNotThrow(() => createThreadStore({ getItem() { throw Error(); }, setItem() { throw Error(); } }).save({ root, replies: [] }, root.id));
+});
+
+// Expose the real loader only in this test bundle, as the browser tests do.
+const bundle = await Bun.build({ entrypoints: ['src/features/messages.js'], target: 'bun', plugins: [{ name: 'thread-cache-test', setup(build) {
+ build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
+ .replace("id: 'messages',", "id: 'messages', testThread: threadFor, testOpen: openNoteRef,") }));
+ build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text())
+ .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { return globalThis.__threadQuery(filter);')
+ .replace(/export function subscribeOn\(([^)]*)\) \{/, 'export function subscribeOn($1) { return () => {};') }));
+} }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const path = '/tmp/coinos-thread-cache-test-' + process.pid + '.mjs';
+await Bun.write(path, await bundle.outputs[0].text());
+try {
+ const { messagesFeature } = await import(path);
+ let requests = [], waiting = [];
+ globalThis.__threadQuery = (filter) => {
+ if (filter.kinds?.includes(1) && (filter.ids || filter['#e'])) {
+ requests.push(filter);
+ return new Promise((resolve) => waiting.push({ filter, resolve }));
+ }
+ return Promise.resolve([]);
+ };
+ const makeFeature = () => {
+ const ui = { screen: 'wallet' };
+ const feature = messagesFeature({ ui, wallet: { loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => d, saveFeatureState() {} },
+ h() {}, render() {}, toast() {}, hook() {} });
+ return { feature, ui };
+ };
+ // Use new IDs, so none of the storage-only tests can seed this lookup.
+ const r = note(400), p = note(401, [['e', r.id, '', 'reply']]), e = note(402, [['e', p.id, '', 'reply']]);
+ const first = makeFeature();
+ const loading = first.feature.testThread(e);
+ assert.equal(loading.status, 'loading');
+ const wait = async (condition) => {
+ for (let i = 0; i < 100; i++) { if (condition()) return; await Bun.sleep(5); }
+ assert(condition(), 'expected loader progress');
+ };
+ await wait(() => waiting.length === 2);
+ for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [p] : []);
+ await wait(() => waiting.length === 2);
+ for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [r] : []);
+ await wait(() => loading.status === 'ready');
+ check('the loader persists the discovered root and intermediate ancestor', () => {
+ const cached = store.find(e.id);
+ assert.equal(cached.rootId, r.id);
+ assert(cached.replies.some((x) => x.id === p.id));
+ assert(cached.replies.some((x) => x.id === e.id));
+ });
+ requests = [];
+ const refreshed = makeFeature();
+ refreshed.ui.noteThread = { rootId: p.id, focusId: e.id, seed: e };
+ const restored = refreshed.feature.testThread(e);
+ check('refresh returns the complete thread synchronously with no loading state', () => {
+ assert.equal(restored.status, 'ready');
+ assert.equal(restored.root.id, r.id);
+ assert(restored.replies.some((x) => x.id === p.id));
+ assert(restored.replies.some((x) => x.id === e.id));
+ assert.equal(refreshed.ui.noteThread.rootId, r.id);
+ });
+ await wait(() => waiting.length === 1);
+ check('background refresh asks for replies to the canonical root', () => {
+ assert.equal(requests.length, 1);
+ assert.deepEqual(requests[0]['#e'], [r.id]);
+ });
+ for (const q of waiting.splice(0)) q.resolve([]);
+ await Bun.sleep(20);
+ check('an empty relay refresh preserves locally cached context', () => {
+ assert.equal(restored.root.id, r.id);
+ assert.equal(restored.replies.length, 2);
+ });
+ const deepLink = makeFeature();
+ requests = [];
+ await deepLink.feature.testOpen({ id: e.id });
+ check('a cached note reference opens immediately without an event lookup', () => {
+ assert.equal(deepLink.ui.noteThread.seed.id, e.id);
+ assert.equal(requests.length, 0);
+ });
+} finally { await rm(path, { force: true }); }