// The feed opened by URL (/feed) paints as the restoring wallet's from its // first frame. It painted as a visitor's — no post button, chips or nav — // until the restore landed a beat later (the vault's scrypt, the wallet's // state, every feature's init: ~0.5 s on a laptop, more on a phone), and // the button "took a while to appear" (Adam, 2026-10-31). Boot now tells // a wallet is about to open (a session list in the tab, a vault the last // unlock opened without a password, watch-only accounts) before the work, // and the page paints for it. A visitor with no wallet still gets none. // Run: bun tools/feed-boot-chrome-test.js import puppeteer from './puppeteer.js'; import { buildHtml } from '../build.js'; import { encryptVault } from '../src/wallet.js'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; let fails = 0; const check = (name, ok, detail = '') => { console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`); if (!ok) fails++; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5331, fetch: (req) => { const p = new URL(req.url).pathname; if (p === '/verify-worker.js' || p.startsWith('/punks')) return new Response(Bun.file('dist' + p)); 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 mnemonic = generateMnemonic(wordlist); const account = { id: 'boot-test', label: 'Boot', type: 'full', mnemonic, passphrase: '', network: 'regtest', persisted: true }; const vault = encryptVault([{ label: account.label, mnemonic, passphrase: '', network: 'regtest' }], ''); // Every mutation batch gets a number; the first batch each piece of the page // showed up in says whether it came with the page or after it. const watch = () => { window.__seen = {}; window.__batch = 0; const SEL = { page: '.feed-page', fab: '.feed-post-fab', chips: '.feed-chips', nav: '.app-bottom-nav', side: '.wallet-side' }; new MutationObserver(() => { window.__batch++; for (const [k, s] of Object.entries(SEL)) if (!(k in window.__seen) && document.querySelector(s)) { window.__seen[k] = { batch: window.__batch, at: Math.round(performance.now()) }; if (k === 'fab') window.__fab0 = document.querySelector(s); } }).observe(document, { childList: true, subtree: true }); }; async function load(name, seed) { const ctx = await browser.createBrowserContext(); // its own storage: each scenario starts clean const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.setViewport({ width: 390, height: 844 }); await page.evaluateOnNewDocument(watch); await page.evaluateOnNewDocument(seed, { account, vault }); await page.goto('http://localhost:5331/feed', { waitUntil: 'domcontentloaded' }); await sleep(3500); const r = await page.evaluate(() => ({ seen: window.__seen, errors: [], fabSame: !!window.__fab0 && document.querySelector('.feed-post-fab') === window.__fab0, chips: [...document.querySelectorAll('.feed-chip')].map((c) => c.textContent.trim() + (c.classList.contains('on') ? '*' : '')), fab: !!document.querySelector('.feed-post-fab'), nav: !!document.querySelector('.app-bottom-nav'), walletOpen: !!document.querySelector('.app-nav-profile, .chat-avatar'), blankNote: localStorage.getItem('btc-wallet-vault-blank'), text: document.body.innerText.replace(/\s+/g, ' ').slice(0, 120), })); r.errors = errors; console.log(`[${name}] ${JSON.stringify(r.seen)} chips=${r.chips.join('|')}`); await ctx.close(); return r; } // with the page, or (the nav rides the boot shell too) before it const withPage = (r, k) => r.seen[k] && r.seen.page && r.seen[k].batch <= r.seen.page.batch; try { // 1. A reload: the tab's session list says which wallet is coming. let r = await load('session', ({ account }) => { localStorage.setItem('btc-wallet-network', 'regtest'); sessionStorage.setItem('btc-wallet-accounts', JSON.stringify({ accounts: [account], activeId: account.id })); }); check('session: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen)); check('session: the chips paint with the page', withPage(r, 'chips')); check('session: the nav paints with the page', withPage(r, 'nav')); check('session: the wallet then opens behind it', r.fab && r.nav && r.walletOpen, r.text); check('session: the button is the same node through the restore (no blink)', r.fabSame); check('session: Popular stood for the visitor copy, then the wallet\'s own chips', r.chips.length >= 2 && r.chips.filter((c) => /Popular/.test(c)).length === 1, r.chips.join('|')); check('session: no page errors', r.errors.length === 0, r.errors.join(' | ')); // 2. A cold start: a vault the last unlock opened without a password. r = await load('vault', ({ vault }) => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-vault', JSON.stringify(vault)); localStorage.setItem('btc-wallet-vault-blank', '1'); }); check('vault: the post button paints with the feed page', withPage(r, 'fab'), JSON.stringify(r.seen)); check('vault: the nav paints with the page', withPage(r, 'nav')); check('vault: the wallet opens behind it', r.fab && r.walletOpen, r.text); check('vault: the same node through the scrypt restore', r.fabSame); check('vault: no page errors', r.errors.length === 0, r.errors.join(' | ')); // 3. The same vault before any unlock left its note: boot can't tell, so // the page paints as a visitor's and grows the button when the wallet // opens — and the unlock leaves the note for next time. r = await load('vault, no note', ({ vault }) => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-vault', JSON.stringify(vault)); }); check('unnoted vault: the button still comes, after the page', r.fab && r.seen.fab && r.seen.page && r.seen.fab.batch > r.seen.page.batch, JSON.stringify(r.seen)); check('unnoted vault: the unlock leaves the note', r.blankNote === '1'); // 4. A visitor: no wallet on this device, nothing to paint for. r = await load('visitor', () => { localStorage.setItem('btc-wallet-network', 'regtest'); }); check('visitor: the feed paints, without a post button', r.seen.page && !r.fab && !r.seen.fab, JSON.stringify(r.seen)); check('visitor: no page errors', r.errors.length === 0, r.errors.join(' | ')); } finally { await browser.close(); server.stop(true); } console.log(fails ? `\n❌ ${fails} failing` : '\n✅ the feed paints for the wallet that is coming'); process.exit(fails ? 1 : 0);