aboutsummaryrefslogtreecommitdiffstats
path: root/svelte.config.js
diff options
context:
space:
mode:
Diffstat (limited to 'svelte.config.js')
0 files changed, 0 insertions, 0 deletions
6 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 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
// 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 <img> 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:<hash>` — 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);