1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
|
// 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 = `<!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: 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);
|