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