diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 09:08:57 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 09:08:57 -0700 |
| commit | 014cf755cc330529c5fa1dedb321b27750b150b1 (patch) | |
| tree | 917ed78ecf97333e2c50f0f8ea55ab98648fb624 | |
| parent | 66113070c59cfabf7b418828d174f70c55a21dd6 (diff) | |
| download | coinosv3-014cf755cc330529c5fa1dedb321b27750b150b1.tar.gz coinosv3-014cf755cc330529c5fa1dedb321b27750b150b1.zip | |
ark ui-test: port the browser e2e to the current app
The old test spawned ~/halwallet's dev server and drove its long-gone UI
(receive-mode select, Settings 'Refresh / consolidate'). It now builds this
repo's app bundle and drives it against the regtest stack: set up Spending,
receive over Ark from alice, send back from the Send form, fund Savings
on-chain and move it into Spending (board), reload. Only the page and the
local backends are reachable. aliceReady uses 'bark maintain' so actions a
killed run left half-done get resumed instead of holding coins locked.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | tools/ark/alice.js | 5 | ||||
| -rw-r--r-- | tools/ark/ui-test.js | 264 |
2 files changed, 127 insertions, 142 deletions
diff --git a/tools/ark/alice.js b/tools/ark/alice.js index 9c33661..b270edd 100644 --- a/tools/ark/alice.js +++ b/tools/ark/alice.js @@ -27,7 +27,10 @@ const expiredCoins = () => { export async function aliceReady() { if (expiredCoins().length) { console.log(' … refreshing alice\'s expired coins first'); - sh(`${ALICE} -q refresh --all >/dev/null`); + // maintain = refresh what's expiring + resume any action a killed run left + // half-done (those hold their coins locked); errors surface below as a + // balance that never comes back + try { sh(`${ALICE} -q maintain >/dev/null 2>&1`); } catch {} // the round's output turns spendable once its funding tx confirms and the // indexer has seen it — keep mining until bark agrees for (let i = 0; i < 20 && (expiredCoins().length || !aliceBalance()); i++) { mine(1); await sleep(1500); } diff --git a/tools/ark/ui-test.js b/tools/ark/ui-test.js index 910c12b..4460cd3 100644 --- a/tools/ark/ui-test.js +++ b/tools/ark/ui-test.js @@ -1,27 +1,25 @@ -// Browser-level e2e of the Ark UI wiring, driven with puppeteer against the -// real dev server + the ark regtest stack: +// Browser-level e2e of the Ark wallet, driven with puppeteer: the real app +// bundle against the real ark regtest stack (captaind :3535, esplora :30002). // -// import wallet -> ark connects -> receive over Ark (alice pays the address -// shown in the Receive tab) -> send over Ark from the Send form -> board -// from the Settings card -> refresh/consolidate -> balances line up. +// 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 (regtest stack up + nothing on :5199) +// Usage: bun tools/ark/ui-test.js (ark regtest stack up) -import { spawn, execSync } from 'node:child_process'; +import { execSync } from 'node:child_process'; import puppeteer from 'puppeteer-core'; import { generateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; +import { ALICE, aliceReady, aliceBalance } from './alice.js'; -const PORT = 5199; -const APP = `http://localhost:${PORT}`; -const BARK = `${process.env.HOME}/bark/target/debug/bark`; -const ALICE = `${BARK} --datadir ${process.env.HOME}/ark-regtest/alice`; 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`); -const aliceBalance = () => JSON.parse(sh(`${ALICE} -q balance`)).spendable_sat; let ok = true; const check = (name, cond, detail = '') => { @@ -29,18 +27,38 @@ const check = (name, cond, detail = '') => { if (!cond) ok = false; }; -// --- dev server --- -const dev = spawn('bun', ['run', 'dev'], { cwd: process.env.HOME + '/halwallet', env: { ...process.env, PORT: String(PORT) }, stdio: 'ignore' }); -for (let i = 0; i < 30; i++) { - try { if ((await fetch(APP)).ok) break; } catch {} - await sleep(500); -} +// 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 = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +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: 'new', args: ['--no-sandbox'] }); +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) => { @@ -57,160 +75,124 @@ const waitText = async (text, ms = 20000) => { } return false; }; -// The number shown next to a label like "ark balance" (sats mode, comma groups) -const labeledNumber = async (label) => { - const txt = await bodyText(); - const esc = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const m = txt.match(new RegExp(esc + String.raw`[\s\n]*([\d,]+)`, 'i')); +// 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; -}; -const waitLabeledNumber = async (label, want, ms = 30000) => { +}, label); +const waitFace = async (label, want, ms = 30000) => { for (let i = 0; i < ms / 500; i++) { - if (await labeledNumber(label) === want) return true; + if (await faceSat(label) === want) return true; await sleep(500); } return false; }; +const tabPane = () => page.evaluate(() => document.querySelector('.tab-pane')?.innerText || ''); try { - // --- configure: regtest, esplora on the ark chain, ark local --- - await page.goto(APP, { waitUntil: 'domcontentloaded' }); - await page.evaluate(() => { - 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'); - }); - await page.reload({ waitUntil: 'domcontentloaded' }); - await sleep(500); - - // --- import a fresh wallet --- - console.log('\n[1] import wallet + ark connects'); - const mnemonic = generateMnemonic(wordlist); - await clickText('button', 'Import existing'); - await sleep(200); - await page.type('textarea', mnemonic); - await clickText('button', 'Open wallet'); - check('wallet opened', await waitText('receive', 15000)); + await aliceReady(); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.uiApp?.ready); - check('ark connected (receive option present)', await (async () => { - for (let i = 0; i < 40; i++) { - const has = await page.evaluate(() => [...document.querySelectorAll('option')].some((o) => o.value === 'ark')); - if (has) return true; - await sleep(500); - } - return false; - })()); + 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'); - // --- receive over ark --- console.log('\n[2] receive over Ark'); - await page.select('select', 'ark'); - check('lazy connect on first use', await waitText('tark1', 15000)); - const arkAddr = await page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim() || ''); - check('ark address shown', /^tark1[a-z0-9]+$/.test(arkAddr), arkAddr.slice(0, 24) + '…'); + await page.evaluate(() => uiApp.ctx.setAccount('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.slice(0, 24) + '…'); + if (!arkAddr) { await snap('no-ark-address'); throw new Error('ark never connected — is captaind up?'); } sh(`${ALICE} -q send ${arkAddr} "25000 sat"`); - check('ark balance shows 25,000', await waitLabeledNumber('ark balance', 25000, 30000), String(await labeledNumber('ark balance'))); + 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); - check('back to receive card after tap', await waitText('tark1', 5000)); + await snap('received'); - // --- send over ark --- console.log('\n[3] send over Ark'); + await clickText('button', 'Send'); + await sleep(500); const aliceAddr = sh(`${ALICE} -q address`); const before = aliceBalance(); - await clickText('.tabs button', 'Send'); - await sleep(300); - await page.type('.mono-input', aliceAddr); - await sleep(400); // destReady re-render reveals the amount input - await page.type('input[type="number"]', '7000'); - const noFeeUi = await page.evaluate(() => { - const txt = document.body.innerText.toLowerCase(); - return !txt.includes('fee rate') && !txt.includes('coin control') && txt.includes('no mining fee'); - }); - check('no fee/coin controls for ark dest', noFeeUi); + await page.type('textarea.send-dest', 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'); - check('ark review shown', await waitText('Send over Ark', 5000)); - await clickText('button.btn-primary', 'Send'); + 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'); check('alice +7000', aliceBalance() === before + 7000, String(aliceBalance() - before)); + check('spending shows 18,000', await waitFace('Spending', 18000), String(await faceSat('Spending'))); await clickText('.card', 'Sent!'); // tap anywhere to proceed - await sleep(300); - - // history shows both ark movements - await clickText('.tabs button', 'History'); await sleep(400); const hist = await bodyText(); - check('history shows ark receive', /received\s*\n?\s*ark/i.test(hist)); - check('history shows ark send', /sent\s*\n?\s*ark/i.test(hist)); - - // rows open a detail view - await clickText('.item', 'sent'); - await sleep(300); - check('ark detail view', await waitText('Coin (VTXO)', 5000)); - check('detail shows destination', (await bodyText()).includes('tark1')); - await clickText('button', 'Back'); - await sleep(300); + check('history shows the receive', /received[\s\S]*\+25,000/i.test(hist)); + check('history shows the send', /-7,000/.test(hist)); - // --- board from settings --- - console.log('\n[4] board'); - await clickText('.tabs button', 'Receive'); - await sleep(300); - await page.select('select', 'address'); - await sleep(300); - const onchainAddr = await page.evaluate(() => document.querySelector('.addr-box').textContent.trim()); - sh(`${BCLI} sendtoaddress ${onchainAddr} 0.001`); - mine(1); - check('onchain funds arrive', await waitText('100,000', 30000)); - // the on-chain receive celebration takes over the tab — tap through it - if ((await bodyText()).toLowerCase().includes('payment received')) { - await clickText('.card', 'Payment received'); - await sleep(400); + console.log('\n[4] Savings -> Spending (board)'); + await page.evaluate(() => uiApp.ctx.setAccount('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); } - await page.select('select', 'ark'); - await sleep(400); - const preBoard = await labeledNumber('ark balance'); - const boardInput = await page.evaluateHandle(() => - [...document.querySelectorAll('input')].find((i) => (i.placeholder || '').toLowerCase().startsWith('board amount'))); - check('board form on receive/ark', !!(await boardInput.asElement())); - await boardInput.asElement().type('30000'); - await clickText('button', 'Board'); - check('boarding success screen', await waitText('Boarding!', 20000)); - check('boarding txid link shown', await page.evaluate(() => - !!document.querySelector('.addr-box') && /^[0-9a-f]{64}$/.test(document.querySelector('.addr-box').textContent.trim()))); - await clickText('button.btn-primary', 'Done'); + 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 page.type('textarea.send-dest', 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 vtxo lands (+29,670)', await waitLabeledNumber('ark balance', preBoard + 29670, 40000), - String(await labeledNumber('ark balance'))); + check('board lands in Spending (+29,670)', await waitFace('Spending', 18000 + 29670, 60000), String(await faceSat('Spending'))); + await snap('boarded'); - // --- refresh / consolidate --- - console.log('\n[5] refresh'); - await clickText('.tabs button', 'Settings'); - await sleep(300); - await clickText('button', 'Refresh / consolidate'); - check('refresh submitted', await waitText('Refresh submitted', 10000)); - const done = await (async () => { - for (let i = 0; i < 45; i++) { - mine(1); - await sleep(2000); - if (await labeledNumber('coins (vtxos)') === 1 && !(await bodyText()).toLowerCase().includes('in progress')) return true; - } - return false; - })(); - check('refresh consolidated to 1 coin', done, String(await labeledNumber('coins (vtxos)'))); - const finalBal = await labeledNumber('ark balance'); - console.log(` final ark balance: ${finalBal} sats`); - - // --- reload: a wallet holding vtxos reconnects on its own --- - console.log('\n[6] reload auto-connects (has vtxos)'); + console.log('\n[5] reload'); await page.reload({ waitUntil: 'domcontentloaded' }); - check('ark balance back without interaction', await waitLabeledNumber('ark balance', finalBal, 20000), - String(await labeledNumber('ark balance'))); - - console.log(ok ? '\n✅ SUCCESS: Ark UI — receive, send, board, refresh all work in the browser' - : '\n❌ some checks failed'); + check('spending back without interaction', await waitFace('Spending', 47670, 30000), String(await faceSat('Spending'))); + await snap('reloaded'); } finally { await browser.close(); - dev.kill(); + 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); |
