diff options
| author | Adam Soltys <[email protected]> | 2026-11-15 13:20:24 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-15 13:20:24 -0700 |
| commit | e4d23a8e32a8feaf0cf7aeb93ce847732ff93330 (patch) | |
| tree | d3641c81250abd49a8ced3e5daa37cf1daf5799a /tools/signup-quick-test.js | |
| parent | 79d4f77f3f70877c0c4951aff5dc0c42800ab73c (diff) | |
| download | coinosv3-e4d23a8e32a8feaf0cf7aeb93ce847732ff93330.tar.gz coinosv3-e4d23a8e32a8feaf0cf7aeb93ce847732ff93330.zip | |
Signing up is a name and a look: no seed phrase to copy or check, no Savings until you want it, and ways back in you add afterwards. "i think 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 if they want to. adding additional auth methods like passkey, nostr, or google should be possible once they've created their account so that they can get back into it with one or all of those methods or by seed phrase." (Adam, 2026-11-15). Where a name can be claimed (mainnet, mutinynet, with Ark and the registrar), the front door's "Create an account" makes the account at once (seed saved to the device, never shown) and goes Claim your name (no Not now) → Choose your look → in. The account is Spending alone; Settings → Wallet → Balance settings has "Add a savings account" (same seed, account 0), and on-chain money or history turning up adds it by itself. Settings → Wallet opens with "Ways back in": add a passkey, Google or a Nostr account, each of which gets a copy of the seed encrypted to its own key (kind 30078, marked recovery), and signing in with it on another device opens this account as itself, no identity adopted; the recovery phrase sits in the same list with Show. Until one exists the wallet carries a quiet "Add a way back into your account" line. The shape and the list survive the session (directory) and sync between devices (snapshot). Regtest and builds without Ark keep the classic phrase flow, and so does the /migrate door's Create. tools/signup-quick-test.js (mainnet, mocked registrar, virtual passkey): the whole sign-up, Add savings, a reload, then a wiped page signs back in with the passkey to the same account and identity. balance-settings-row, send-lnaddr-advance and onboard-backup reach their seed wallets through the phrase door or on regtest; features-smoke, onboard-cover, onboard-migrate-door, nostr-login, gift-profile-setup, passkey-lock and seed-cache pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6SwkivduFDDkMmnXjZ
Diffstat (limited to 'tools/signup-quick-test.js')
| -rw-r--r-- | tools/signup-quick-test.js | 167 |
1 files changed, 167 insertions, 0 deletions
diff --git a/tools/signup-quick-test.js b/tools/signup-quick-test.js new file mode 100644 index 0000000..c80547d --- /dev/null +++ b/tools/signup-quick-test.js @@ -0,0 +1,167 @@ +// 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)', s.faces === 1 && !s.carousel && /spending/i.test(s.label || ''), JSON.stringify({ faces: s.faces, carousel: s.carousel, label: s.label })); + check('a quiet line asks for a way back in', /add a way back into your account/i.test(s.body)); + 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('only Spending is listed, with Add a savings account', views.join() === 'spending' && !!(await page.$('#add-savings')), views.join()); + 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'))); + check('the reminder line is gone once there is a way back in', await (async () => { await page.goBack(); await page.goBack(); await sleep(600); const gone = !/add a way back into your account/i.test(await text()); await openWalletPage(); return gone; })()); + // the list and the reminder, as the app sees them (record a method directly) + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Show')?.click()); + await sleep(500); + check('Show opens the recovery phrase card', /recovery phrase/i.test(await text())); + + 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); |
