aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 09:08:57 -0700
committerAdam Soltys <[email protected]>2026-10-07 09:08:57 -0700
commit014cf755cc330529c5fa1dedb321b27750b150b1 (patch)
tree917ed78ecf97333e2c50f0f8ea55ab98648fb624
parent66113070c59cfabf7b418828d174f70c55a21dd6 (diff)
downloadcoinosv3-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.js5
-rw-r--r--tools/ark/ui-test.js264
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);