diff options
| author | Adam Soltys <[email protected]> | 2026-09-13 09:48:54 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-13 09:48:54 -0700 |
| commit | 38b3d091e8e1bece958c0d7e80a5ee78434de933 (patch) | |
| tree | a6ef243527c8f8c9499d90ffb9e2c43d9d03a8b2 /tools/onboard-backup-test.js | |
| parent | 810d3d8bfd523a8a2a4a51cd2e426d72af049d13 (diff) | |
| download | coinosv3-38b3d091e8e1bece958c0d7e80a5ee78434de933.tar.gz coinosv3-38b3d091e8e1bece958c0d7e80a5ee78434de933.zip | |
The front door points at your own keys.
Four buttons of equal weight, and only the sign-in ones said what they
did. "Get started" said nothing, so the concrete offer on that screen was
Google — which is a convenience wrapped around a seed phrase, not an
alternative to one.
Now the page says what the wallet is before it offers a button, the way
in reads "Create a new wallet" with the cost under it (a minute, twelve
words), and the three sign-ins sit below a line labelled "or, if you
already have an account".
And the second half of the same problem: a wallet that arrives by sign-in
has a recovery phrase its owner has never seen and nobody can reissue.
So the wizard shows it once, right after the first login, while it still
reads as setting up rather than as a warning out of nowhere. "I've
written it down" is the way on; "Later" is a real answer, and the wallet
then keeps one quiet line — not a modal, not dismissible, gone the moment
the phrase is actually seen, here or in settings.
seedSeen records that per account: creating shows the words, importing
means they typed them, revealing in settings counts. Only a derived seed
starts false, which is exactly the case worth asking about.
tools/onboard-backup-test.js covers both halves, including that "Later"
leaves a reminder that survives a reload and leads somewhere.
The sixteen tests that clicked the old button by its text are updated,
and it has an id now so its copy can change without breaking them again.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
Diffstat (limited to 'tools/onboard-backup-test.js')
| -rw-r--r-- | tools/onboard-backup-test.js | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js new file mode 100644 index 0000000..3b242dd --- /dev/null +++ b/tools/onboard-backup-test.js @@ -0,0 +1,121 @@ +// Two things about the front door. +// +// One: making your own keys has to look like the way in. Four equally +// weighted buttons, where only the sign-in ones said what they did, sent +// people straight past the seed phrase — which IS the wallet — into a login +// that is only a convenience wrapped around one. +// +// Two: a wallet that arrived by SIGN-IN has a recovery phrase its owner has +// never seen, and nobody can reissue it. So it gets shown once, during +// setup, and if they put it off the wallet keeps one quiet line about it. +// +// Run: bun tools/onboard-backup-test.js +import puppeteer from 'puppeteer-core'; +import { bech32 } from '@scure/base'; +import { generateSecretKey } from 'nostr-tools/pure'; +import { seedFromNostrKey } from '../src/nostr-login.js'; +import { buildHtml } from '../build.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; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5291, 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 click = (page, t, sel = 'button') => page.evaluate((s, x) => { + const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x)); + if (e) { e.click(); return true; } return false; +}, sel, t.toLowerCase()); +const text = (page) => page.evaluate(() => document.body.innerText); +const waitText = async (page, x, ms = 30000) => { + for (let i = 0; i < ms / 250; i++) { if ((await text(page)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } + return false; +}; + +// Sign in with a pasted nsec and stop at whatever the wizard shows next. +// Its own browser context, so each run meets a genuinely fresh front door +// rather than the wallet the previous one left in localStorage. +async function signIn() { + const ctx = await browser.createBrowserContext(); + const page = await ctx.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + // wait for the front door to actually be painted before poking it + await waitText(page, 'sign in with nostr'); + const sk = generateSecretKey(); + const nsec = bech32.encode('nsec', bech32.toWords(sk), 1000); + await click(page, 'sign in with nostr'); + await waitText(page, 'log in with nostr'); + await page.waitForSelector('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', { timeout: 15000 }); + await page.type('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', nsec); + await click(page, 'log in'); + return { page, ctx, seed: seedFromNostrKey(sk) }; +} + +try { + console.log('[the start page]'); + { + const page = await browser.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' }); + await sleep(1500); + const front = await page.evaluate(() => { + const btns = [...document.querySelectorAll('button')].filter((b) => b.offsetParent); + const primary = document.getElementById('onb-create'); + const idx = btns.indexOf(primary); + const signins = btns.filter((b) => /sign in with/i.test(b.textContent)); + return { + label: primary ? primary.textContent.trim() : null, + first: idx === 0 || (idx >= 0 && idx < btns.findIndex((b) => /sign in with/i.test(b.textContent))), + primaryTop: primary ? primary.getBoundingClientRect().top : -1, + signinTop: signins.length ? signins[0].getBoundingClientRect().top : -1, + divider: !!document.querySelector('.onb-or'), + body: (document.querySelector('.onb p') || {}).textContent || '', + }; + }); + check('the way in says what it makes', /wallet/i.test(front.label || ''), front.label || 'missing'); + check('...and comes before the sign-in buttons', front.first && front.primaryTop < front.signinTop, + `create at ${Math.round(front.primaryTop)}, first sign-in at ${Math.round(front.signinTop)}`); + check('...with a line between them, so they read as the alternative', front.divider); + check('the page says what the wallet actually is', /twelve words|seed|phrase/i.test(front.body), + front.body.slice(0, 60) + '…'); + await page.close(); + } + + console.log('\n[signing in, then writing it down]'); + { + const { page, ctx, seed } = await signIn(); + check('a sign-in is asked to write the phrase down', await waitText(page, 'write down your recovery phrase')); + const shown = await page.evaluate(() => [...document.querySelectorAll('.words .w .t')].map((e) => e.textContent.trim())); + check('...and the real twelve words are on screen', shown.length === 12 && shown.join(' ') === seed, + shown.length + ' words' + (shown.join(' ') === seed ? ', matching the derived seed' : ', WRONG')); + await click(page, "i've written it down"); + check('confirming lands in the wallet', await waitText(page, 'receive')); + const nag = await text(page); + check('...and there is no reminder afterwards', !/still unwritten/i.test(nag)); + await page.reload({ waitUntil: 'domcontentloaded' }); + check('...not after a reload either', await waitText(page, 'receive') && !/still unwritten/i.test(await text(page))); + await ctx.close(); + } + + console.log('\n[signing in, then putting it off]'); + { + const { page, ctx } = await signIn(); + check('the ask appears', await waitText(page, 'write down your recovery phrase')); + await click(page, 'later'); + check('later lands in the wallet too', await waitText(page, 'receive')); + check('...but the wallet keeps a quiet reminder', /still unwritten/i.test(await text(page))); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText(page, 'receive'); + check('...which survives a reload', /still unwritten/i.test(await text(page))); + // and it is a way IN, not just a scold + await click(page, 'write it down'); + check('tapping it opens the recovery phrase', await waitText(page, 'recovery phrase')); + await ctx.close(); + } +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the front door points at your own keys' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
