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