aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 13:10:43 -0700
committerAdam Soltys <[email protected]>2026-10-07 13:10:43 -0700
commitc82e042aa592e4e619d0c280b02486eeb859d358 (patch)
tree6fb03c99cf227789ea7773128f935a317fc40411 /tools
parent6faf6b6fe49b8e51a38e0106c858341faf2145ef (diff)
downloadcoinosv3-c82e042aa592e4e619d0c280b02486eeb859d358.tar.gz
coinosv3-c82e042aa592e4e619d0c280b02486eeb859d358.zip
Profile links paint whole on the first frame: the server renders /npub1…, /nprofile1… and /<name> with the kind 0, latest posts, follow counts and mentioned faces inlined (stale-while-refresh cache, 2 s budget), the app seeds its caches from it, and the big avatar never flashes punk art while a lookup runs
A cold visit to a profile link showed a punk, '–' counts and no posts for 9 s. Server-side relay answers settle once events stop arriving (idle 400 ms) instead of waiting out a relay that never sends EOSE: a cold build is ~1.9 s. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'tools')
-rw-r--r--tools/profile-page-test.js126
1 files changed, 126 insertions, 0 deletions
diff --git a/tools/profile-page-test.js b/tools/profile-page-test.js
new file mode 100644
index 0000000..19b8b0b
--- /dev/null
+++ b/tools/profile-page-test.js
@@ -0,0 +1,126 @@
+// A shared profile link (/npub1…, /<coinos name>): the server renders the
+// person's kind 0, latest posts and follow counts into the page, and the app
+// paints all of it on its first frame — no punk, no "–" counts, no empty
+// column while relays answer. Relays are blackholed here, so whatever shows
+// came from the page.
+// Run: bun tools/profile-page-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { parseProfileRef, buildProfile, renderProfilePage } from '../feed/profile-page.js';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const check = (name, fn) => Promise.resolve(fn()).then(() => console.log('✓ ' + name));
+
+const A = generateSecretKey(), B = generateSecretKey();
+const pkA = getPublicKey(A), pkB = getPublicKey(B);
+const now = Math.floor(Date.now() / 1000);
+const kind0 = (sk, m, at = now - 9000) => finalizeEvent({ kind: 0, created_at: at, tags: [], content: JSON.stringify(m) }, sk);
+const posts = [
+ finalizeEvent({ kind: 1, created_at: now - 60, tags: [], content: 'Sunrise over the ridge </script><b>not markup</b>' }, A),
+ finalizeEvent({ kind: 1, created_at: now - 120, tags: [['p', pkB]], content: 'Thanks nostr:' + npubEncode(pkB) + ' for the tip' }, A),
+];
+const events = [kind0(A, { name: 'vesper', display_name: 'Vesper', picture: '/punks/18.webp', about: 'Climber & coder' }),
+ kind0(A, { name: 'old' }, now - 99999), kind0(B, { name: 'Rae' }), ...posts];
+const match = (f) => events.filter((e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)));
+const query = async (relays, f) => match(f);
+const primal = async () => ({ profile: null, follows: 901, followers: 589 });
+
+const npub = npubEncode(pkA);
+await check('profile paths parse; files, app routes and notes do not', () => {
+ assert.equal(parseProfileRef('/' + npub).pk, pkA);
+ assert.equal(parseProfileRef('/Asoltys').name, 'asoltys');
+ for (const p of ['/sw.js', '/chat', '/feed', '/note1abc', '/a/b', '/', '/npub1garbage']) assert.equal(parseProfileRef(p), null, p);
+});
+const ref = { ...parseProfileRef('/' + npub) };
+const data = await buildProfile(ref, { query, primal });
+await check('the newest kind 0, the posts, the counts and the mentioned faces', () => {
+ assert.equal(JSON.parse(data.profile.content).display_name, 'Vesper');
+ assert.deepEqual(data.notes.map((e) => e.id), posts.map((e) => e.id));
+ assert.deepEqual(data.counts, { follows: 901, followers: 589 });
+ assert.deepEqual(data.profiles.map((e) => e.pubkey), [pkB]);
+});
+await check('nobody there is left to the client', async () => {
+ assert.equal(await buildProfile({ pk: 'e'.repeat(64), relays: [] }, { query, primal: async () => null }), null);
+});
+
+const html = await buildHtml({ minify: true, pwa: false });
+const page1 = renderProfilePage(html, data, { ...ref, path: npub }, 'https://v3.coinos.io');
+await check('preview tags name the person; the JSON cannot close its tag', () => {
+ assert.match(page1, /<title>Vesper on Coinos<\/title>/);
+ assert.match(page1, /<meta property="og:description" content="Climber &amp; coder">/);
+ const json = page1.slice(page1.indexOf('id="boot-profile">'));
+ assert(!json.slice(0, json.indexOf('</script>')).includes('</script'), 'no closing tag inside the JSON');
+});
+
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const u = new URL(req.url), p = u.pathname;
+ if (p.startsWith('/punks') || p === '/verify-worker.js') return new Response(Bun.file('dist' + p));
+ if (p === '/' + npub && !u.searchParams.has('plain')) return new Response(page1, { headers: { 'content-type': 'text/html' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(() => {
+ window.WebSocket = function () { const ws = new EventTarget(); ws.readyState = 0; ws.send = () => {}; ws.close = () => {}; return ws; };
+ window.__frames = [];
+ const tick = () => {
+ try {
+ const a = document.querySelector('.profile-avatar');
+ if (!a) return;
+ window.__frames.push({
+ punk: !!a.querySelector('.punk'), img: a.classList.contains('ava-img'),
+ social: (document.querySelector('.social-line')?.textContent || '').replace(/\s+/g, ' '),
+ posts: document.querySelectorAll('.notes-feed > *').length,
+ });
+ } catch {}
+ };
+ setInterval(tick, 16);
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ await page.goto(server.url.href + npub, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.__frames.length > 5, { timeout: 8000 });
+ await sleep(300);
+ const frames = await page.evaluate(() => window.__frames);
+ await check('the first frame with anything in it is complete: face, counts, posts', () => {
+ const i = frames.findIndex((f) => f.img || f.social || f.posts);
+ // before it, only the blank boot shell (a frame or so): no placeholder face, counts or posts
+ assert(frames.slice(0, i).every((f) => !f.img && !f.social && !f.posts && !f.punk));
+ const f = frames[i];
+ assert.equal(f.img, true, 'picture');
+ assert.match(f.social, /901 Following/);
+ assert.match(f.social, /589 Followers/);
+ assert(f.posts >= 2, 'posts: ' + f.posts);
+ });
+ await check('no punk at any point', () => assert(frames.every((f) => !f.punk)));
+ await check('mentions read as names', async () => {
+ const text = await page.evaluate(() => document.body.innerText);
+ assert.match(text, /Vesper/);
+ assert.match(text, /Rae/);
+ assert.match(text, /Sunrise over the ridge <\/script><b>not markup<\/b>/);
+ });
+ await check('no page errors', () => assert.deepEqual(errs, []));
+ await page.close();
+
+ // the plain page (server down or slow): a quiet circle, never the punk,
+ // while the (here: silent) relays are asked
+ const plain = await browser.newPage();
+ await plain.evaluateOnNewDocument(() => {
+ window.WebSocket = function () { const ws = new EventTarget(); ws.readyState = 0; ws.send = () => {}; ws.close = () => {}; return ws; };
+ window.__punk = false;
+ setInterval(() => { if (document.querySelector('.profile-avatar .punk')) window.__punk = true; }, 16);
+ });
+ await plain.goto(server.url.href + npub + '?plain', { waitUntil: 'domcontentloaded' });
+ await plain.waitForSelector('.profile-avatar', { timeout: 8000 });
+ await sleep(1200);
+ await check('without the server: no punk while the lookup runs', async () => {
+ assert.equal(await plain.evaluate(() => window.__punk), false);
+ });
+ await plain.close();
+} finally { await browser.close(); server.stop(true); }
+console.log('\n✅ profile pages');
+process.exit(0);