aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/balance-settings-row-test.js13
-rw-r--r--tools/chat-layout-test.js2
-rw-r--r--tools/footer-support-test.js41
-rw-r--r--tools/onboard-backup-test.js4
-rw-r--r--tools/pos-test.js20
-rw-r--r--tools/send-lnaddr-advance-test.js3
-rw-r--r--tools/signup-quick-test.js203
7 files changed, 265 insertions, 21 deletions
diff --git a/tools/balance-settings-row-test.js b/tools/balance-settings-row-test.js
index 89e68d9..38d34b3 100644
--- a/tools/balance-settings-row-test.js
+++ b/tools/balance-settings-row-test.js
@@ -2,7 +2,9 @@
// balance card's gear opens. "I simply cannot find this option" (Nils,
// 2026-11-12) — the Spending gear was the only way in.
// Run: bun tools/balance-settings-row-test.js (NO_BUILD=1 to reuse dist/)
-import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js';
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
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; };
@@ -32,13 +34,16 @@ try {
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet'));
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
- await click('create a new wallet'); await sleep(600);
- await seedWords(page);
- await skipSeedCheck(page);
+ // on mainnet Create makes a Spending-only account (signup-quick-test); a
+ // seed wallet with Savings alone comes in through the seed-phrase door
+ await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400);
+ await page.click('#onb-restore'); await sleep(400);
+ await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
if (await waitText('claim a username', 8000)) { await click('not now'); await sleep(800); }
await waitText('receive');
await sleep(1500);
check('savings-only wallet lists Savings alone', await (async () => { await openWalletPage(); const v = await page.$$eval('.balance-settings-row', (l) => l.map((e) => e.dataset.view)); await page.goBack(); await page.goBack(); await sleep(500); return v.join() === 'savings'; })());
+ await waitText('set up a spending account', 30000);
await click('set up a spending account'); await sleep(800);
if (await waitText('claim a username', 5000)) { await click('not now'); await sleep(800); }
await waitText('receive'); await sleep(800);
diff --git a/tools/chat-layout-test.js b/tools/chat-layout-test.js
index 7c79858..a0f00ad 100644
--- a/tools/chat-layout-test.js
+++ b/tools/chat-layout-test.js
@@ -164,7 +164,7 @@ try {
console.log('\n[direct messages]');
await page.click('.chat-back');
await sleep(500);
- check('footer returns when the conversation closes', await page.$eval('.footer', (el) => getComputedStyle(el).display !== 'none'));
+ // (no footer to bring back: with a wallet open the pickers live in Settings, 2026-11-15)
await click('button', 'New message');
await setInput('input[placeholder*="npub" i]', '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798');
await click('button', 'Open');
diff --git a/tools/footer-support-test.js b/tools/footer-support-test.js
index a37c070..0de6c9a 100644
--- a/tools/footer-support-test.js
+++ b/tools/footer-support-test.js
@@ -1,5 +1,6 @@
-// The footer's "Show your support" link opens the hat shop, from any page,
-// and only shows once a wallet is open (the shop pays from it).
+// "Show your support" lives on Settings → About (2026-11-15: the footer is
+// gone once a wallet is open; language and currency top the Settings hub) and opens the hat shop; it shows only
+// once a wallet is open (the shop pays from it).
// Run: bun tools/footer-support-test.js
import puppeteer from './puppeteer.js';
import { generateMnemonic } from '@scure/bip39';
@@ -18,31 +19,43 @@ const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '
const page = await browser.newPage();
const errs = []; page.on('pageerror', (e) => errs.push(String(e)));
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 link = () => page.evaluate(() => [...document.querySelectorAll('.footer button')].some((b) => b.textContent.trim() === 'Show your support'));
+const link = () => page.evaluate(() => !!document.getElementById('about-support'));
+const footerOnly = () => page.evaluate(() => { const f = document.querySelector('.footer'); return f ? { selects: f.querySelectorAll('select').length, other: f.querySelectorAll('button, a').length, text: f.innerText.replace(/\s+/g, ' ').trim() } : null; });
+const openAbout = async () => {
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^About/.test(e.textContent.trim())).click()); await sleep(500);
+};
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' });
await sleep(600);
check('no link before there is a wallet', !(await link()));
- await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400);
+ await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400);
+ await page.click('#onb-restore'); await sleep(400);
await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) {
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /^(not now|skip|continue|later)$/i.test(b.textContent.trim()))?.click());
await sleep(400);
}
await sleep(1500);
- check('the footer has "Show your support"', await link());
- // from another page: the feed
- await page.evaluate(() => [...document.querySelectorAll('nav button, .bottom-nav button')].find((b) => /feed/i.test(b.textContent))?.click()); await sleep(800);
- await page.evaluate(() => [...document.querySelectorAll('.footer button')].find((b) => b.textContent.trim() === 'Show your support').click());
+ const f = await footerOnly();
+ check('with a wallet open there is no footer', !f || (await page.$eval('.footer', (el) => getComputedStyle(el).display === 'none')), JSON.stringify(f));
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ check('Settings carries the language and currency', await page.evaluate(() => document.querySelectorAll('.settings-prefs select').length) === 2);
+ if (process.env.SHOT2) await page.screenshot({ path: process.env.SHOT2 });
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^About/.test(e.textContent.trim())).click()); await sleep(500);
+ const about = await page.evaluate(() => document.querySelector('.about-card')?.innerText || '');
+ check('About has the credits, How it works, the theme and the commit', /Adam Soltys/.test(about) && /git\.coinos\.io/.test(about) && /How it works/.test(about) && /(Dark|Light) mode/.test(about) && /Running git commit [0-9a-f]{7}/.test(about), about.replace(/\n/g, ' | '));
+ check('...and "Show your support"', await link());
+ const th = await page.evaluate(() => document.documentElement.dataset.theme);
+ await page.click('#about-theme'); await sleep(300);
+ check('the theme switch works from there', await page.evaluate(() => document.documentElement.dataset.theme) !== th);
+ await page.click('#about-theme'); await sleep(300);
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT });
+ await page.click('#about-support');
await sleep(1200);
const txt = await page.evaluate(() => document.body.innerText);
- check('it opens the hat shop', /hat/i.test(txt) && !!(await page.evaluate(() => document.querySelector('.hat-btn') || /Crown|Top hat|hats?/i.test(document.querySelector('.card')?.innerText || ''))), txt.slice(0, 160).replace(/\n/g, ' | '));
- await page.evaluate(() => { document.querySelector('.bottom-nav, nav')?.remove(); window.scrollTo(0, 1e6); });
- await sleep(300);
- if (process.env.SHOT) await (await page.$('.footer')).screenshot({ path: process.env.SHOT });
- else await page.screenshot({ path: '/tmp/footer-support.png', fullPage: true });
- // || '/tmp/footer-support.png', fullPage: true });
+ check('it opens the hat shop', /hat/i.test(txt), txt.slice(0, 160).replace(/\n/g, ' | '));
check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); }
console.log(ok ? 'PASS' : 'FAIL');
diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js
index 8fc1071..946f342 100644
--- a/tools/onboard-backup-test.js
+++ b/tools/onboard-backup-test.js
@@ -64,6 +64,10 @@ try {
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
+ // regtest: the classic phrase flow. Where a name can be claimed (mainnet,
+ // mutinynet) Create is the quick sign-up instead — signup-quick-test.js.
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(1500);
// Two choices and nothing else (2026-11-06): a new wallet, or one you
// already have — the sign-in methods wait behind the second.
diff --git a/tools/pos-test.js b/tools/pos-test.js
index 55349cb..2b6a0b7 100644
--- a/tools/pos-test.js
+++ b/tools/pos-test.js
@@ -30,7 +30,7 @@ const ctx = {
};
const feature = posFeature(ctx); window.__feature = feature;
function render() { document.querySelector('#app').replaceChildren(feature.screenView() || h('div', { id: 'closed' }, 'closed')); }
-window.test = { open: () => { feature.openPos(); }, init: () => { feature.init(); render(); },
+window.test = { open: () => { feature.openPos(); }, request: () => { feature.openAmountRequest(); }, init: () => { feature.init(); render(); },
cards: () => { document.querySelector('#app').replaceChildren(...feature.posSettingsCards()); }, settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, get sales() { return (state.pos || {}).sales || []; }, ui };
render();
`;
@@ -128,6 +128,24 @@ try {
check("the old device-wide flag is dropped on start (it can't say whose it was)", await page.evaluate(() => (test.ui.pos = null, true)) && (await boot('owner')) && await page.evaluate(() => localStorage.getItem('btc-wallet-pos-mode') === null));
check('another wallet on the same device does not start in point of sale', !(await boot('someone-else')));
check('the owner still does, after anyone else has been in', await boot('owner'));
+
+ console.log('\n[Receive → Amount]');
+ await page.evaluate(() => { localStorage.removeItem('btc-wallet-pos-mode:owner'); test.ui.pos = null; test.request(); }); await pause(50);
+ let rq = await text();
+ check('opens as Request an amount, without the till around it', /Request an amount/.test(rq) && !/ask for a tip|Tips today|Sales today/i.test(rq) && !(await page.$('.pos-sale')), rq.slice(0, 200));
+ const before = await page.evaluate(() => test.sales.length);
+ await type('.pos-amount', '2100');
+ await page.click('#pos-charge'); await pause(100);
+ rq = await text();
+ check('straight to the invoice, no tip prompt', /Scan to pay/i.test(rq) && /2,100/.test(rq) && !!(await page.$('.pos-qr')), rq.slice(0, 200));
+ const req = await page.evaluate(() => test.sales[test.sales.length - 1]);
+ check('the request is for the amount, no tip, and marked', req.billSat === 2100 && req.tipSat === 0 && req.request === true, JSON.stringify(req));
+ await page.evaluate(() => test.settle(true)); await pause(100);
+ check('paid, with Done back to Receive', /Done/.test(await text()) && !/New sale/.test(await text()));
+ await click('Done'); await pause(50);
+ check('Done closes it', !!(await page.$('#closed')));
+ await page.evaluate(() => { test.ui.pos = null; test.open(); }); await pause(50);
+ check("the till's sales list leaves requests out", !(await text()).includes('2,100'), String(before));
check('no browser errors', errors.length === 0);
} finally { await browser.close(); server.stop(true); }
console.log('\n✅ point of sale');
diff --git a/tools/send-lnaddr-advance-test.js b/tools/send-lnaddr-advance-test.js
index fd6c1a1..14640ca 100644
--- a/tools/send-lnaddr-advance-test.js
+++ b/tools/send-lnaddr-advance-test.js
@@ -43,7 +43,8 @@ try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' });
await sleep(400);
- await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400);
+ await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400);
+ await page.click('#onb-restore'); await sleep(400);
await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet');
// mainnet onboarding: skip the username step (and anything after it)
for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) {
diff --git a/tools/signup-quick-test.js b/tools/signup-quick-test.js
new file mode 100644
index 0000000..a269faf
--- /dev/null
+++ b/tools/signup-quick-test.js
@@ -0,0 +1,203 @@
+// Signing up is a name and a look, nothing else (Adam, 2026-11-15): "i'd like
+// to move away from requiring people to backup and verify a seed phrase to
+// sign up. we should just have them claim a name and pick their avatar/cover
+// and they should be in. we should also not create a savings account by
+// default but let them add it later … adding additional auth methods like
+// passkey, nostr, or google should be possible once they've created their
+// account."
+//
+// Mainnet, with the Ark server's info read for real and the names registrar
+// mocked (nothing is claimed): Create an account → no twelve words, no check
+// → Claim your name (no "Not now") → Choose your look → in, on a Spending-only
+// wallet (no carousel, no Savings face); the wallet line asks for a way back
+// in; Settings → Wallet shows "Ways back in" (passkey via a CDP virtual
+// authenticator → listed, the line goes away) and "Add a savings account"
+// (→ the Savings face appears); a reload keeps both.
+// Run: bun tools/signup-quick-test.js (NO_BUILD=1 to reuse dist/)
+import puppeteer from './puppeteer.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; };
+if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ const f = Bun.file('dist' + path);
+ return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+const registered = [];
+const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' };
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ const u = new URL(r.url());
+ if (u.origin === server.url.origin || u.hostname === 'ark.coinos.io') return r.continue();
+ if (u.hostname !== 'names.coinos.io') return r.abort();
+ if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '{}' });
+ const reply = (j, status = 200) => r.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) });
+ if (u.pathname === '/register' && r.method() === 'POST') { const j = JSON.parse(r.postData() || '{}'); registered.push(j.name); return reply({ ok: true, name: j.name }); }
+ if (u.pathname.startsWith('/name/')) return reply({ taken: false });
+ if (u.pathname.startsWith('/pubkey/')) return reply({});
+ return reply({});
+});
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const openWalletPage = async () => {
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500);
+ await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(600);
+};
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('WebAuthn.enable');
+ await cdp.send('WebAuthn.addVirtualAuthenticator', { options: {
+ protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, hasPrf: true, automaticPresenceSimulation: true } });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#onb-create', { timeout: 15000 });
+ check('the front door says Create an account', (await page.$eval('#onb-create', (e) => e.textContent.trim())) === 'Create an account');
+ await page.click('#onb-create');
+ check('no seed screens: straight to Claim your name', await waitText('claim your name', 20000));
+ const t1 = await text();
+ check('no twelve words or check on the way', !(await page.$('.words')) && !/which word/i.test(t1));
+ check('no "Not now" on a sign-up\'s name step', !/not now/i.test(t1));
+ await page.waitForSelector('.onb input[type=text]', { timeout: 20000 });
+ await page.type('.onb input[type=text]', 'quicktester');
+ await click('claim');
+ check('the name goes to the registrar', await (async () => { for (let i = 0; i < 80 && !registered.length; i++) await sleep(250); return registered.includes('quicktester'); })(), registered.join());
+ check('then the look: avatar and cover', await (async () => { for (let i = 0; i < 80; i++) { if (await page.$('.onb-cover')) return true; await sleep(250); } return false; })());
+ await click('continue'); await sleep(800);
+ if (await waitText("you're all set", 8000)) await click('enter');
+ await page.waitForSelector('.balance-face', { timeout: 20000 });
+ await sleep(1500);
+ const shape = async () => page.evaluate(() => ({
+ faces: document.querySelectorAll('.balance-face').length,
+ carousel: !!document.querySelector('.bal-carousel'),
+ label: document.querySelector('.balance-face .small.faint')?.textContent.trim(),
+ body: document.body.innerText,
+ }));
+ let s = await shape();
+ check('in, on Spending alone (no Savings face, no name on it)', s.faces === 1 && !s.carousel && !s.label, JSON.stringify({ faces: s.faces, carousel: s.carousel, label: s.label }));
+ // asked once, and only with money in the account (2026-11-15): not on an empty one
+ check('no way-back-in prompt on an empty account', !/add a way back into your account/i.test(s.body) && !(await page.$('.ways-back-prompt')));
+ // money arrives in Spending (the ark feature, found on the heap, made to
+ // report a balance; then a repaint)
+ const giveBalance = async () => {
+ const protoH = await page.evaluateHandle(() => Object.prototype);
+ const all = await page.queryObjects(protoH);
+ const found = await page.evaluate((objs) => {
+ const f = objs.find((o) => { try { return o && o.id === 'ark' && typeof o.spendingSat === 'function'; } catch { return false; } });
+ if (!f) return false;
+ f.spendingSat = () => 5000;
+ window.dispatchEvent(new Event('resize'));
+ document.querySelector('.tabs button')?.click(); document.querySelector('.tabs button')?.click();
+ return true;
+ }, all);
+ await all.dispose(); await protoH.dispose();
+ await sleep(800);
+ return found;
+ };
+ check('(test: found the ark feature)', await giveBalance());
+ check('with a balance, it asks once for a way back in', !!(await page.$('.ways-back-prompt')), (await text()).slice(0, 300).replace(/\n/g, ' | '));
+ await page.evaluate(() => [...document.querySelectorAll('.ways-back-prompt button')].find((b) => /not now/i.test(b.textContent)).click()); await sleep(400);
+ check('Not now puts it away', !(await page.$('.ways-back-prompt')));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500);
+ await giveBalance();
+ check('...and never again, not after a reload', !(await page.$('.ways-back-prompt')));
+ for (let i = 0; i < 60 && !(await page.$('#recv-amount')); i++) await sleep(250);
+ check('Receive has an Amount button', !!(await page.$('#recv-amount')));
+ check('no History heading on an empty wallet', !/\bHISTORY\b/.test(await page.evaluate(() => document.body.innerText)));
+ if (process.env.SHOT3) await page.screenshot({ path: process.env.SHOT3 });
+ await page.click('#recv-amount'); await sleep(500);
+ check('Amount opens the amount pad', !!(await page.$('.pos-amount')) && /request an amount/i.test(await text()));
+ if (process.env.SHOT4) await page.screenshot({ path: process.env.SHOT4 });
+ await click('back'); await sleep(500);
+ check('Back returns to Receive', !(await page.$('.pos-amount')) && !!(await page.$('#recv-amount')));
+ const acct = await page.evaluate(() => JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'));
+ const a0 = (acct.accounts || [])[0] || {};
+ check('the account: spending-only, phrase unseen, saved to the device', a0.kind === 'spending' && a0.savingsLater && a0.seedSeen === false && a0.persisted, JSON.stringify({ kind: a0.kind, later: a0.savingsLater, seen: a0.seedSeen, persisted: a0.persisted }));
+
+ await openWalletPage();
+ check('Settings → Wallet opens with Ways back in', !!(await page.$('.ways-back')) && /ways back in/i.test(await text()));
+ const views = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view));
+ check('the ways back in read as auth buttons', (await page.$$eval('.ways-back-add', (l) => l.map((e) => e.textContent.trim()))).join('|') === 'Passkey auth|Google auth|Nostr auth|Pen and paper', (await page.$$eval('.ways-back-add', (l) => l.map((e) => e.textContent.trim()))).join('|'));
+ check('only Spending is listed, with Add a savings account', views.join() === 'spending' && !!(await page.$('#add-savings')), views.join());
+ if (process.env.SHOT2) { await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); await page.evaluate(() => document.querySelector('.balance-settings-row').closest('.card').scrollIntoView()); await sleep(300); await page.screenshot({ path: process.env.SHOT2 }); await page.evaluate(() => document.documentElement.removeAttribute('data-theme')); }
+ if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.ways-back').scrollIntoView()); await page.screenshot({ path: process.env.SHOT }); }
+ // a passkey: the relays are stubbed, so the publish fails offline — the
+ // error says so and nothing is listed
+ await page.evaluate(() => document.querySelector('.ways-back-add[data-way="passkey"]')?.click());
+ await sleep(4000);
+ const pk = await page.evaluate(() => ({ rows: [...document.querySelectorAll('.ways-back-row')].map((e) => e.dataset.way), err: document.querySelector('.ways-back .notice.err')?.textContent || '' }));
+ check('adding a passkey lists it', pk.rows.includes('passkey') && !pk.err, JSON.stringify(pk));
+ const backups = await page.evaluate(() => (window.__relayGuard.events || []).filter((e) => e.kind === 30078 && e.tags.some((t) => t[0] === 'd' && t[1] === 'coinos:wallet:v1')));
+ // the list and the reminder, as the app sees them (record a method directly)
+ check('the phrase card waits behind Pen and paper', !(await page.$('.recovery-card')));
+ await page.evaluate(() => document.querySelector('.ways-back-add[data-way="phrase"]')?.click());
+ await sleep(500);
+ check('Pen and paper opens the recovery phrase card', !!(await page.$('.recovery-card .words')));
+
+ await page.evaluate(() => document.getElementById('add-savings')?.click()); await sleep(800);
+ const views2 = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view));
+ check('Add a savings account adds Savings beside Spending', views2.join() === 'spending,savings' && !(await page.$('#add-savings')), views2.join());
+ await page.goBack(); await page.goBack(); await sleep(800);
+ s = await shape();
+ check('the wallet now has both faces', s.carousel && s.faces === 2, JSON.stringify({ faces: s.faces, carousel: s.carousel }));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500);
+ s = await shape();
+ check('a reload keeps Savings', s.carousel && s.faces === 2);
+
+ // A new device: everything here wiped, the relays holding only what the
+ // passkey published. Sign in → Passkey opens THIS account, as itself.
+ const before = await page.evaluate(() => {
+ const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
+ const a = (st.accounts || [])[0] || {};
+ return { mnemonic: a.mnemonic, nostrPk: a.nostrPk };
+ });
+ before.backups = backups;
+ check('the passkey published one encrypted seed copy', before.backups.length >= 1 && !before.backups[0].content.includes(before.mnemonic.split(' ')[0] + ' '), String(before.backups.length));
+ const backup = before.backups[before.backups.length - 1];
+ await page.evaluateOnNewDocument((ev) => {
+ const send = WebSocket.prototype.send;
+ WebSocket.prototype.send = function (d) {
+ try {
+ const m = JSON.parse(d);
+ if (m[0] === 'REQ' && m.slice(2).some((f) => (f.kinds || []).includes(30078) && (f.authors || []).includes(ev.pubkey))) {
+ const ws = this;
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })), 1);
+ }
+ } catch {}
+ return send.call(this, d);
+ };
+ }, backup);
+ await page.evaluate(async () => {
+ localStorage.clear(); sessionStorage.clear();
+ for (const d of (await indexedDB.databases?.() || [])) if (d.name) indexedDB.deleteDatabase(d.name);
+ localStorage.setItem('btc-wallet-network', 'mainnet');
+ });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForSelector('#onb-signin', { timeout: 15000 });
+ await page.click('#onb-signin'); await sleep(500);
+ await click('passkey');
+ for (let i = 0; i < 120 && !(await page.$('.balance-face')); i++) await sleep(250);
+ if (!(await page.$('.balance-face'))) console.log((await text()).slice(0, 800), errs);
+ const after = await page.evaluate(() => {
+ const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
+ const a = (st.accounts || [])[0] || {};
+ const login = Object.keys(localStorage).concat([]).filter((k) => /:nostrlogin$/.test(k)).map((k) => localStorage.getItem(k));
+ return { mnemonic: a.mnemonic, nostrPk: a.nostrPk, kind: a.kind, login, onb: localStorage.getItem('btc-wallet-onb-step') };
+ });
+ check('the passkey opened the same account', after.mnemonic === before.mnemonic);
+ check('as itself: same identity, the passkey key not adopted', after.nostrPk === before.nostrPk && !after.login.some((v) => v && v.includes(backup.pubkey)), JSON.stringify({ was: before.nostrPk?.slice(0, 8), now: after.nostrPk?.slice(0, 8) }));
+ check('no wizard on the way back in', !after.onb && !(await page.$('.onb')));
+ check('no page errors', errs.length === 0, errs.join(' | '));
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? 'PASS' : 'FAIL');
+process.exit(ok ? 0 : 1);