// Avatar pictures are whatever their owner uploaded — often the full-size
// original (megabytes, to paint a 30px circle). We downscale each one once
// and keep the thumbnail beside the profile, so later boots paint the face
// from localStorage with no network at all.
//
// Here: a wallet is given a profile whose picture is a large cross-origin
// image, and we check that a thumbnail is made and persisted, that the small
// avatar then paints from it alone (the original is never requested again),
// and that a refresh of the profile doesn't throw the thumbnail away.
//
// Run: bun tools/avatar-thumb-test.js
import puppeteer from './puppeteer.js';
import { generateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { seedPubkey } from '../src/nostr.js';
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; };
// Generate a real PNG in the test browser; no external scratchpad fixture.
let big;
const html = await buildHtml({ minify: true, pwa: false });
let originalHits = 0;
let failedCorsHits = 0;
const server = Bun.serve({
port: 5235,
async fetch(req) {
const u = new URL(req.url);
if (u.pathname === '/fail.png') {
if (req.headers.get('sec-fetch-mode') === 'cors') failedCorsHits++;
return new Response('unavailable', { status: 503, headers: { 'access-control-allow-origin': '*' } });
}
if (u.pathname.startsWith('/punks')) {
const f = Bun.file('dist' + u.pathname);
if (await f.exists()) return new Response(await f.arrayBuffer(), { headers: { 'content-type': 'image/webp' } });
return new Response('no', { status: 404 });
}
// the shape that broke a real avatar: the picture is behind a redirect,
// so the bytes land in the HTTP cache under the FINAL url when the plain
// background-image loads them — and a crossOrigin
for the same
// picture then reuses that no-CORS entry and fails the CORS check
if (u.pathname === '/redir.png') return new Response(null, { status: 302, headers: { location: '/big.png', 'access-control-allow-origin': '*' } });
if (u.pathname === '/big.png') {
originalHits++;
return new Response(big, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*', 'cache-control': 'no-store' } });
}
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();
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; };
try {
big = Buffer.from(await page.evaluate(() => {
const c = document.createElement('canvas');
c.width = c.height = 1024;
const ctx = c.getContext('2d');
const pixels = ctx.createImageData(c.width, c.height);
for (let i = 0; i < pixels.data.length; i += 4) {
pixels.data[i] = (i * 13) % 251;
pixels.data[i + 1] = (i * 29) % 253;
pixels.data[i + 2] = (i * 7) % 255;
pixels.data[i + 3] = 255;
}
ctx.putImageData(pixels, 0, 0);
return c.toDataURL('image/png').split(',')[1];
}), 'base64');
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5235/', { 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);
// the face in the header is the seed's own nostr identity
const pk = seedPubkey(mnemonic, '', 0);
check('found the wallet identity', !!pk, pk ? pk.slice(0, 12) + '…' : 'none');
// give it a picture: a large cross-origin image
// the seed-keyed slot and the watch-only xpub mirror both look like
// `btc-wallet-cache:` — seed the row under every one of them
const bases = await page.evaluate((pkHex) => {
const ks = Object.keys(localStorage).filter((k) => /^btc-wallet-cache:[0-9a-f]+$/.test(k));
for (const b of ks) localStorage.setItem(b + ':profiles', JSON.stringify({
[pkHex]: { name: 'Face Test', picture: 'http://localhost:5235/big.png', t: Date.now() },
}));
return ks.length;
}, pk);
check('seeded the profile cache', bases > 0, bases + ' cache slot(s)');
originalHits = 0;
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await sleep(2500);
const row = await page.evaluate(() => {
const rows = window.coinosCache.keys().filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
.flatMap((k) => Object.values(JSON.parse(window.coinosCache.getItem(k) || '{}')));
const r = rows.find((x) => x && x.thumb) || rows[0] || {};
return { hasThumb: typeof r.thumb === 'string', kind: (r.thumb || '').slice(0, 20), len: (r.thumb || '').length, thumbFor: r.thumbFor === r.picture };
});
check('a thumbnail was made and persisted', row.hasThumb, row.kind + ' ' + row.len + ' chars');
check('it is pinned to the picture it came from', row.thumbFor);
check('it is small', row.len > 0 && row.len < 16000, row.len + ' chars');
// The whole point of the thumbnail is that no circle in the app ever has
// to fetch the original again. The feed's face is the biggest of them, so
// the thumbnail has to cover IT at phone pixel density — if someone grows
// that circle without growing THUMB_PX, every feed row quietly starts
// pulling down full-size originals again.
const covers = await page.evaluate(() => {
const el = document.createElement('div');
el.className = 'chat-avatar note-avatar';
el.style.position = 'absolute'; el.style.visibility = 'hidden';
document.body.appendChild(el);
const css = parseFloat(getComputedStyle(el).width);
el.remove();
const rows = window.coinosCache.keys().filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
.flatMap((k) => Object.values(JSON.parse(window.coinosCache.getItem(k) || '{}')));
const r = rows.find((x) => x && (x.thumb || '').length) || {};
return new Promise((done) => {
const i = new Image();
i.onload = () => done({ css, thumb: i.naturalWidth });
i.onerror = () => done({ css, thumb: 0 });
i.src = r.thumb || '';
});
});
check('the thumbnail covers the biggest circle at 3x',
covers.thumb >= covers.css * 3, `${covers.thumb}px thumbnail for a ${covers.css}px circle`);
// the small avatar paints from the thumbnail alone
const style = await page.evaluate(() => {
const a = document.querySelector('.header-avatar .ava-img') || document.querySelector('.ava-img');
return a ? a.getAttribute('style') : 'NONE header=' + (document.querySelector('.header-avatar') || {}).innerHTML;
});
console.log(' header avatar:', String(style).slice(0, 160));
check('the header avatar paints from the local copy', /data:image/.test(style));
check('...and never asks for the original', !/localhost:5235\/big\.png/.test(style), style.slice(0, 40));
// and a reload makes no request for the original at all
originalHits = 0;
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await sleep(2000);
check('a later boot fetches no picture at all', originalHits === 0, originalHits + ' request(s)');
// A punk picture is our own art: painted from disk at the size being drawn,
// never fetched from coinos.io (whose copies 502 for a third of the set).
await page.evaluate((pkHex) => {
for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
localStorage.setItem(k + ':profiles', JSON.stringify({
[pkHex]: { name: 'Punk Test', picture: 'https://v3.coinos.io/punks/7.webp', t: Date.now() },
}));
}, pk);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await sleep(1200);
const punkStyle = await page.evaluate(() => {
const a = document.querySelector('.header-avatar .ava-img') || document.querySelector('.ava-img');
return a ? a.getAttribute('style') : '';
});
check('a punk picture paints from the local small copy', /punks-sm\/7\.webp/.test(punkStyle), punkStyle.slice(0, 60));
check('...and never from coinos.io', !/coinos\.io/.test(punkStyle));
// And someone with no picture at all still gets a punk — the small one.
await page.evaluate((pkHex) => {
for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
localStorage.setItem(k + ':profiles', JSON.stringify({ [pkHex]: { name: 'Bare', picture: null, t: Date.now() } }));
}, pk);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await sleep(1200);
const punkSrc = await page.evaluate(() => {
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);
// 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.
await page.evaluate(async () => {
for (const k of window.coinosCache.keys().filter((x) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(x))) window.coinosCache.removeItem(k);
await window.coinosCache.flush();
});
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
const strangerSrc = await page.evaluate(() => {
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');
// A picture behind a redirect, loaded plainly first (as the avatar always
// is) and only then thumbnailed.
await page.evaluate((pkHex) => {
for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
localStorage.setItem(k + ':profiles', JSON.stringify({
[pkHex]: { name: 'Redirect Test', picture: 'http://127.0.0.1:5235/redir.png', t: Date.now(),
thumbFail: 'http://127.0.0.1:5235/redir.png', thumbFailAt: Date.now(), thumbFails: 5 },
}));
}, pk);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await sleep(3000);
const redirThumb = await page.evaluate(() => {
const rows = window.coinosCache.keys().filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
.flatMap((k) => Object.values(JSON.parse(window.coinosCache.getItem(k) || '{}')));
// The seed above wrote this profile into EVERY wallet's cache, but only
// the one actually on screen paints the avatar and so makes the
// thumbnail. Asking the first matching row was a coin toss on
// localStorage key order — take the best copy, not an arbitrary one.
const all = rows.filter((x) => x && /redir/.test(x.picture || ''));
const r = all.find((x) => (x.thumb || '').length) || all[0] || {};
return { thumb: (r.thumb || '').length, fail: !!r.thumbFail };
});
check('an old thumbnail failure retries immediately and clears on success', redirThumb.thumb > 0 && !redirThumb.fail,
redirThumb.thumb ? redirThumb.thumb + ' chars' : 'failed');
// If the corrected path also fails, start a new backoff and keep it
// across boots rather than repeatedly downloading a broken picture.
await page.evaluate((pkHex) => {
for (const k of Object.keys(localStorage).filter((x) => /^btc-wallet-cache:[0-9a-f]+$/.test(x)))
localStorage.setItem(k + ':profiles', JSON.stringify({
[pkHex]: { name: 'Failure Test', picture: 'http://127.0.0.1:5235/fail.png', t: Date.now(),
thumbFail: 'http://127.0.0.1:5235/fail.png', thumbFailAt: Date.now(), thumbFails: 5 },
}));
}, pk);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await sleep(1500);
const failure = await page.evaluate(() => window.coinosCache.keys()
.filter((k) => /^btc-wallet-cache:[0-9a-f]+:profiles$/.test(k))
.flatMap((k) => Object.values(JSON.parse(window.coinosCache.getItem(k) || '{}')))
.find((r) => r.thumbFailVersion === 1));
check('a fresh failure starts a new backoff', failedCorsHits === 1 && failure?.thumbFails === 1);
failedCorsHits = 0;
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await sleep(1500);
check('current failures still respect backoff after reload', failedCorsHits === 0);
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ faces come back instantly' : '\n❌ failed');
process.exit(ok ? 0 : 1);