// Browser-level e2e of the Ark wallet, driven with puppeteer: the real app // bundle against the real ark regtest stack (captaind :3535, esplora :30002). // // fresh wallet -> set up Spending -> receive over Ark (alice pays the tark1 // address on the Receive tab) -> send over Ark from the Send form -> fund // Savings on-chain and move it to Spending (board) -> reload: the balance // comes back on its own. // // Usage: bun tools/ark/ui-test.js (ark regtest stack up) import { execSync } from 'node:child_process'; import puppeteer from '../puppeteer.js'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { ALICE, aliceReady, aliceBalance } from './alice.js'; const BCLI = `docker exec bc bitcoin-cli -rpcwallet=coinos`; const SHOTS = process.env.SHOTS; // dir: save a screenshot per step const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim(); const mine = (n = 1) => sh(`${BCLI} generatetoaddress ${n} $(${BCLI} getnewaddress) >/dev/null`); let ok = true; const check = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) ok = false; }; // the real app, with a handle on its state for setup and assertions const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true, plugins: [{ name: 'ark-ui-test', setup(build) { build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + ` window.uiApp = { ui, wallet, ctx, render, enterWallet, get ready() { return !_bootDeciding; } }; ` })); build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); } }] }); if (!bundle.success) throw new Error(bundle.logs.join('\n')); const html = `
`; const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/' ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); const page = await browser.newPage(); page.on('pageerror', (e) => console.log(' [pageerror]', e.message)); page.on('console', (m) => process.env.DEBUG && console.log(' [console]', m.type(), m.text().slice(0, 200))); await page.setViewport({ width: 390, height: 844 }); // only the page and the local regtest backends — no relays, no registrar await page.setRequestInterception(true); const local = (u) => u.startsWith(server.url.origin) || u.startsWith('data:') || /^https?:\/\/(localhost|127\.0\.0\.1):(3535|30002)\//.test(u); page.on('request', (r) => (local(r.url()) ? r.continue() : r.abort())); await page.evaluateOnNewDocument(() => { localStorage.setItem('btc-wallet-network', 'regtest'); localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' })); localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); }); // helpers — NB innerText respects CSS text-transform, so match case-insensitively let shot = 0; const snap = async (name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${String(++shot).padStart(2, '0')}-${name}.png` }); }; const bodyText = () => page.evaluate(() => document.body.innerText); const clickText = async (sel, text) => { const found = await page.evaluate((sel, text) => { const el = [...document.querySelectorAll(sel)].find((e) => e.textContent.trim().toLowerCase().includes(text.toLowerCase())); if (el) { el.click(); return true; } return false; }, sel, text); if (!found) throw new Error(`clickText: no ${sel} with "${text}"`); }; const waitText = async (text, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await bodyText()).toLowerCase().includes(text.toLowerCase())) return true; await sleep(250); } return false; }; // the amount on a balance card face ("Spending" / "Savings"), in sats const faceSat = (label) => page.evaluate((label) => { const f = [...document.querySelectorAll('.balance-face')] .find((x) => x.innerText.split('\n').some((l) => l.trim().toLowerCase() === label.toLowerCase())); const m = f && f.innerText.match(/([\d,]+)\s*sats/i); return m ? Number(m[1].replaceAll(',', '')) : null; }, label); const waitFace = async (label, want, ms = 30000) => { for (let i = 0; i < ms / 500; i++) { if (await faceSat(label) === want) return true; await sleep(500); } return false; }; // An address arrives the way a paste does — whole. Typed a key at a time, // a re-render mid-typing can drop or reorder characters (the app then // rightly refuses a bad checksum). const pasteDest = (text) => page.evaluate((text) => { const el = document.querySelector('textarea.send-dest'); el.focus(); el.value = text; el.dispatchEvent(new Event('input', { bubbles: true })); }, text); // Select a balance card and open its Receive tab. The carousel glides to the // card and landing on one opens its history — and a glide can settle back // on the other card — so keep asking until the account sticks, then open // Receive (the tab a receive's celebration takes over). const openReceive = async (account) => { for (let i = 0; i < 20; i++) { await page.evaluate((a) => uiApp.ctx.setAccount(a), account); await sleep(750); if (await page.evaluate((a) => uiApp.ctx.getAccount() === a, account)) break; } await sleep(750); await page.evaluate(() => { uiApp.ui.tab = 'receive'; uiApp.render(); }); }; const tabPane = () => page.evaluate(() => document.querySelector('.tab-pane')?.innerText || ''); try { await aliceReady(); await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.uiApp?.ready); console.log('\n[1] fresh wallet + spending account'); await page.evaluate(async (mn) => { await uiApp.enterWallet(mn, '', { fresh: true, generated: true }); uiApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step'); uiApp.render(); }, generateMnemonic(wordlist)); check('wallet opened', await waitText('Set up a spending account', 15000)); await clickText('button', 'Set up a spending account'); await sleep(300); await clickText('button', 'Not now'); // skip claiming a username check('spending card shown', await waitFace('Spending', 0, 15000)); await snap('spending'); console.log('\n[2] receive over Ark'); await openReceive('spending'); let arkAddr = ''; for (let i = 0; i < 120 && !arkAddr; i++) { arkAddr = ((await tabPane()).match(/tark1[a-z0-9]+/) || [''])[0]; if (!arkAddr) await sleep(250); } check('ark address on the Receive tab', !!arkAddr, arkAddr ? arkAddr.slice(0, 24) + '…' : 'tab ' + (await page.evaluate(() => uiApp.ui.tab)) + ': ' + (await tabPane()).slice(0, 200).replace(/\n/g, ' | ')); if (!arkAddr) { await snap('no-ark-address'); throw new Error('ark never connected — is captaind up?'); } // A new wallet's first mailbox read is its baseline: whatever it finds is // history and never celebrated. Pay only once that read is behind us, or // the payment can land inside it and the celebration check is a coin toss. const baselineDone = () => page.evaluate(() => new Promise((res) => { const req = indexedDB.open('coinos-ark'); req.onerror = () => res(false); req.onsuccess = () => { try { const all = req.result.transaction('states').objectStore('states').getAll(); all.onsuccess = () => res(all.result.some((v) => v && Array.isArray(v.movements) && !v.baselinePending)); all.onerror = () => res(false); } catch { res(false); } }; })); let baselined = false; for (let i = 0; i < 120 && !baselined; i++) { baselined = await baselineDone(); if (!baselined) await sleep(250); } check('the first mailbox read (the baseline) is done', baselined); sh(`${ALICE} -q send ${arkAddr} "25000 sat"`); check('spending shows 25,000', await waitFace('Spending', 25000), String(await faceSat('Spending'))); check('payment celebration shown', await waitText('Payment received', 10000)); await clickText('.card', 'Payment received'); // tap to acknowledge await sleep(400); await snap('received'); console.log('\n[3] send over Ark'); await clickText('button', 'Send'); await sleep(500); const aliceAddr = sh(`${ALICE} -q address`); const before = aliceBalance(); await pasteDest(aliceAddr); await sleep(800); check('no fee controls for an ark dest', /no mining fee/i.test(await tabPane()) && !/fee rate|coin control/i.test(await tabPane())); await page.type('.tab-pane input[placeholder="0"]', '7000'); await snap('send-form'); await clickText('button.btn-primary', 'Review transaction'); await sleep(800); check('review shows the amount, no network fee', /7,000 sats/.test(await tabPane()) && /network fee\s*none/i.test(await tabPane())); await snap('review'); await clickText('.tab-pane button.btn-primary', 'Send'); check('sent view', await waitText('Sent!', 15000)); await snap('sent'); // alice's CLI wallet sees the coin once it has read its mailbox let aliceGot = 0; for (let i = 0; i < 30 && aliceGot !== 7000; i++) { aliceGot = aliceBalance() - before; if (aliceGot !== 7000) await sleep(500); } check('alice +7000', aliceGot === 7000, String(aliceGot)); check('spending shows 18,000', await waitFace('Spending', 18000), String(await faceSat('Spending'))); await clickText('.card', 'Sent!'); // tap anywhere to proceed let hist = ''; for (let i = 0; i < 40; i++) { hist = await bodyText(); if (/received[\s\S]*\+25,000/i.test(hist) && /-7,000/.test(hist)) break; await sleep(250); } check('history shows the receive', /received[\s\S]*\+25,000/i.test(hist)); check('history shows the send', /-7,000/.test(hist)); console.log('\n[4] Savings -> Spending (board)'); await openReceive('savings'); let btcAddr = ''; for (let i = 0; i < 40 && !btcAddr; i++) { btcAddr = ((await tabPane()).match(/bcrt1[a-z0-9]+/) || [''])[0]; if (!btcAddr) await sleep(250); } check('on-chain address on the Savings Receive tab', !!btcAddr, btcAddr); sh(`${BCLI} sendtoaddress ${btcAddr} 0.001`); mine(1); check('savings shows 100,000', await waitFace('Savings', 100000, 40000), String(await faceSat('Savings'))); if (/payment received/i.test(await bodyText())) { await clickText('.card', 'Payment received'); await sleep(400); } // sending Savings to your own Spending address opens the move panel instead await clickText('button', 'Send'); await sleep(500); await pasteDest(arkAddr); check('own Spending address opens the move panel', await waitText('Moving from Savings instead', 10000)); await page.type('.balance-move input', '30000'); await sleep(300); check('board fee previewed', /330/.test(await page.evaluate(() => document.querySelector('.balance-move')?.innerText || ''))); await snap('move-panel'); await clickText('.balance-move button.btn-primary', 'Move to Spending'); const broadcast = await page.waitForFunction(() => uiApp.ui.arkBoarded || uiApp.ui.arkError, { timeout: 20000 }) .then(() => page.evaluate(() => uiApp.ui.arkError || 'ok')).catch(() => 'timed out'); check('board funded and cosigned', broadcast === 'ok', broadcast); check('board shows as moving into Spending', /moving…/i.test(await bodyText())); await snap('boarding'); // the success screen lives on the Receive tab (we came in through Send) await clickText('button', 'Receive'); check('boarding success screen', await waitText('On its way!', 5000)); await snap('boarded-screen'); await clickText('button.btn-primary', 'Done'); mine(2); check('board lands in Spending (+29,670)', await waitFace('Spending', 18000 + 29670, 60000), String(await faceSat('Spending'))); await snap('boarded'); console.log('\n[5] reload'); await page.reload({ waitUntil: 'domcontentloaded' }); check('spending back without interaction', await waitFace('Spending', 47670, 30000), String(await faceSat('Spending'))); await snap('reloaded'); } finally { await browser.close(); server.stop(true); } console.log(ok ? '\n✅ SUCCESS: Ark UI — receive, send, board all work in the browser' : '\n❌ some checks failed'); process.exit(ok ? 0 : 1);