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
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);
|