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