// The padlock on a fresh (passwordless) wallet must never cost you your // session: the set-a-password ask renders over the open wallet, declining it // lands you exactly where you were, the logo stays a way home, and the // wallet settings speak only of the on-chain wallet until Spending is set up. // Setting a password locks in place: watch-only home, closed padlock. // Run: bun tools/padlock-flow-test.js import puppeteer from './puppeteer.js'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { buildHtml } from '../build.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let fails = 0; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) fails++; }; const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 5236, fetch: () => 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(); await page.setViewport({ width: 420, height: 880 }); const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t); // the padlock lives on the wallet settings: open them from the balance card's gear first const padlock = async (pg, title) => { await pg.evaluate(() => document.querySelector('.balance-gear')?.click()); await sleep(500); return pg.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => n.title === x); if (b) { b.click(); return true; } return false; }, title); }; const body = () => page.evaluate(() => document.body.innerText); const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await body()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; try { await page.goto('http://localhost:5236/', { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('button', 'Create a new wallet'); await sleep(500); await click('button', 'Have an existing seed'); await sleep(400); await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('button', 'Open wallet'); check('wallet open', await waitText('receive')); await sleep(2000); console.log('\n[the wallet settings before opting into Spending]'); await page.evaluate(() => document.querySelector('.balance-gear')?.click()); await sleep(600); let txt = await body(); check('shows the on-chain wallet', /savings/i.test(txt), txt.slice(0, 140).replace(/\n+/g, ' | ')); check('and no un-asked-for Spending wallet', !/spending/i.test(txt), txt.slice(0, 140).replace(/\n+/g, ' | ')); await click('button', 'Back'); await sleep(500); check('back home', await waitText('receive')); console.log('\n[padlock with no password: declining is free]'); await padlock(page, 'Lock wallet'); await sleep(500); txt = await body(); check('it asks for a password in place', (await page.$$('input[type=password]')).length >= 2, txt.slice(0, 120).replace(/\n+/g, ' | ')); check('with a way to decline', /not now/i.test(txt)); await click('button', 'Not now'); await sleep(600); txt = await body(); check('declining lands back where the padlock was tapped (the wallet settings)', /security/i.test(txt) && !(await page.$('input[type=password]')), txt.slice(0, 120).replace(/\n+/g, ' | ')); console.log('\n[the logo still means home]'); await page.evaluate(() => document.querySelector('.brand')?.click()); await sleep(600); txt = await body(); check('logo goes home, not to a password prompt', /receive/i.test(txt) && !/unlock saved|enter your password/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | ')); console.log('\n[setting a password locks in place]'); await padlock(page, 'Lock wallet'); await sleep(500); const fields = await page.$$('input[type=password]'); check('password form is up', fields.length >= 2, `${fields.length} field(s)`); await fields[0].type('hunter22'); await fields[1].type('hunter22'); await click('button', 'Save'); await sleep(1200); txt = await body(); check('still home (watch-only), not signed out', /receive/i.test(txt) && /balance/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | ')); await page.evaluate(() => document.querySelector('.balance-gear')?.click()); await sleep(500); const closed = await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.title && /unlock/i.test(b.title))); check('padlock is closed', closed); await page.evaluate(() => document.querySelector('.brand')?.click()); await sleep(400); console.log('\n[and the padlock now asks for the password]'); await padlock(page, 'Unlock'); await sleep(600); txt = await body(); check('unlock needs the password', /password/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | ')); // ---- a second wallet meets the vault (fresh device) ---------------------- const ctx2 = await browser.createBrowserContext(); const p2 = await ctx2.newPage(); await p2.setViewport({ width: 420, height: 880 }); const click2 = (t) => p2.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, t); const body2 = () => p2.evaluate(() => document.body.innerText); const waitText2 = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await body2()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; }; const importWallet = async () => { await click2('Have an existing seed'); await sleep(400); await p2.waitForSelector('textarea'); await p2.type('textarea', generateMnemonic(wordlist)); await click2('Open wallet'); return waitText2('receive'); }; // another wallet comes in the way the app offers it now: your profile → // Sign into another account (→ a new one, once others exist) → import const addWallet = async () => { await p2.evaluate(() => document.querySelector('.header-avatar')?.click()); await sleep(800); await click2('Sign into another account'); await sleep(500); await click2('Sign into a new account'); await sleep(500); await click2('Import existing'); await sleep(300); // the sign-in screen's import tab return importWallet(); }; // the wallets on this device, as the identity switcher lists them (the // one open plus the others) const walletCount = async () => { await p2.evaluate(() => document.querySelector('.header-avatar')?.click()); await sleep(800); await click2('Sign into another account'); await sleep(500); const n = await p2.evaluate(() => document.querySelectorAll('.confirm-pop .clickable').length + 1); await p2.evaluate(() => document.querySelector('.confirm-pop-backdrop')?.click()); await sleep(300); await p2.evaluate(() => document.querySelector('.brand')?.click()); await sleep(500); return n; }; console.log('\n[a wallet created after a reload still auto-saves]'); await p2.goto('http://localhost:5236/', { waitUntil: 'domcontentloaded' }); await p2.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await p2.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click2('Create a new wallet'); await sleep(500); check('wallet A open', await importWallet()); await sleep(2000); await p2.reload({ waitUntil: 'domcontentloaded' }); // session restore: vault untouched this session check('back after reload', await waitText2('receive')); await sleep(1000); check('wallet B open', await addWallet()); await sleep(2000); await padlock(p2, 'Lock wallet'); await sleep(600); txt = await body2(); check('padlock offers to SET a password (B is saved, vault is passwordless)', /protect this device/i.test(txt) && !/already saved on this device/i.test(txt), txt.slice(0, 140).replace(/\n+/g, ' | ')); let f2 = await p2.$$('input[type=password]'); await f2[0].type('hunter22'); await f2[1].type('hunter22'); await click2('Save'); await sleep(1200); check('locked in place', await waitText2('receive')); console.log('\n[setting the password kept every saved wallet]'); await p2.reload({ waitUntil: 'domcontentloaded' }); check('reload keeps the locked view on screen', await waitText2('receive')); await padlock(p2, 'Unlock'); await sleep(600); check('re-entry asks for the password', /unlock saved wallets|enter your password/i.test(await body2())); await (await p2.$('input[type=password]')).type('hunter22'); await click2('Unlock'); check('password opens it', await waitText2('receive')); await sleep(1000); let n = await walletCount(); check('both wallets survived the re-encrypt', n === 2, `${n} wallet(s)`); console.log('\n[a new wallet against a passworded vault: the ask explains itself]'); await p2.reload({ waitUntil: 'domcontentloaded' }); // session restore again: vault password not in memory check('back after reload', await waitText2('receive')); await sleep(1000); check('wallet C open', await addWallet()); await sleep(2000); await padlock(p2, 'Lock wallet'); await sleep(600); txt = await body2(); check('it says WHICH password it wants', /already saved on this device/i.test(txt), txt.slice(0, 160).replace(/\n+/g, ' | ')); await (await p2.$('input[type=password]')).type('wrong-guess'); await click2('Lock'); await sleep(600); check('a wrong password is refused', /wrong password/i.test(await body2())); await p2.evaluate(() => { const i = document.querySelector('input[type=password]'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); }); await (await p2.$('input[type=password]')).type('hunter22'); await click2('Lock'); await sleep(1500); check('the right one saves C and locks in place', await waitText2('receive')); await p2.reload({ waitUntil: 'domcontentloaded' }); check('locked view survives the reload', await waitText2('receive')); await padlock(p2, 'Unlock'); await sleep(600); await (await p2.$('input[type=password]')).type('hunter22'); await click2('Unlock'); check('unlocked once more', await waitText2('receive')); await sleep(1000); n = await walletCount(); check('all three wallets are in the vault', n === 3, `${n} wallet(s)`); await ctx2.close(); } catch (e) { check('run completed', false, e.message); } finally { await browser.close(); server.stop(true); } console.log(fails ? '\n❌ failures above' : '\n✅ padlock flow behaves'); process.exit(fails ? 1 : 0);