aboutsummaryrefslogtreecommitdiffstats
path: root/tools/ark/custody-ui-test.js
diff options
context:
space:
mode:
Diffstat (limited to 'tools/ark/custody-ui-test.js')
-rw-r--r--tools/ark/custody-ui-test.js136
1 files changed, 136 insertions, 0 deletions
diff --git a/tools/ark/custody-ui-test.js b/tools/ark/custody-ui-test.js
new file mode 100644
index 0000000..9c238f5
--- /dev/null
+++ b/tools/ark/custody-ui-test.js
@@ -0,0 +1,136 @@
+// The custody card on the Spending settings page: three tiers, coinos holding
+// the coins by default, a coin past its timelock shown as held (not expired),
+// and switching tiers persists and reprices the page. Real app with a local
+// Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-ui-test.js
+import assert from 'node:assert/strict';
+import puppeteer from '../puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'custody-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; },
+ setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, autoRefreshCalls: [] };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, 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'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); // the local preset honors expired coins
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 97, ppm: 250 }, { thresholdBlocks: 199, ppm: 1000 }, { thresholdBlocks: 2161, ppm: 2000 }];
+ window.coopManager = {
+ honorsExpired: true,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 5 }, // 29 days left, five hops deep
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000, depth: 1 }, // past its timelock
+ { id: 'coin-2', amountSat: 800, state: 'spendable', expiryHeight: 973921, depth: 6 }, // deep but not worth 300 sats
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 8640, roundIntervalSecs: 120, refreshFees: { baseFeeSat: 300, ppmExpiryTable: table } }, _tipH: 969751,
+ chain: { tipHeight: async () => 969751 },
+ vtxos() { return this.state.vtxos; },
+ _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; },
+ balance() { return { spendableSat: 25800, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; },
+ refreshFee(inputs, tip) {
+ let u = 0;
+ for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0);
+ return 300 + Math.ceil(u / 1e6);
+ },
+ async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; },
+ async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; },
+ unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.setSpending(true);
+ coopApp.openWalletSettings('spending');
+ }, generateMnemonic(wordlist));
+ await page.waitForSelector('.ark-custody');
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const text = () => page.evaluate(() => document.body.innerText);
+
+ let tx = await text();
+ assert.match(tx, /Custody/);
+ const opts = await page.$$eval('.ark-custody option', (es) => es.map((e) => e.value + '=' + e.textContent));
+ assert.deepEqual(opts, ['custodial=Allow coins to expire', 'self=Minimize renewal cost', 'exit=Minimize exit cost']);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), 'no fees');
+ assert.equal(await page.$eval('#ark-custody-select', (e) => e.value), 'custodial', 'coins expire by default');
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /keeps its own exit until it expires/);
+ assert.doesNotMatch(tx, /Coins renew in the last hours|Every transfer adds a hop/, 'only the chosen strategy is described');
+ assert.doesNotMatch(tx, /timelock|Spending coin carries|old Coinos/, 'no jargon on the card');
+ const order = await page.$$eval('.card h4, .card h3', (es) => es.map((e) => e.textContent).filter((x) => /Spending coins|Custody strategy|Renewal/.test(x)));
+ assert.deepEqual(order, ['Spending coins', 'Custody strategy'], 'coins first, then the strategy; no explainer cards');
+ assert.match(tx, /held by Coinos/, 'the coin past its timelock is held, not "expired"');
+ assert.doesNotMatch(tx, /5,000 sats\s+expired/, 'the row never says "expired"');
+ assert.doesNotMatch(tx, /nothing renews on its own/, 'the renewal explainer is not on the page…');
+ const renewSheet = async () => { await page.click('.ark-renew-ask'); await sleep(200); const t = await text(); await page.evaluate(() => [...document.querySelectorAll('.ark-renew-confirm button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); return t; };
+ assert.match(await renewSheet(), /With “Allow coins to expire”, nothing renews on its own[\s\S]*more than 15 days left/, '…it is on the Renew confirmation, with the fee schedule');
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true });
+ console.log('✓ the custody card: a drop-down of three strategies with their prices, coins expire by default, one description, a past-timelock coin shown as held');
+
+ // the unilateral exit only counts the coin still inside its timelock
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.textContent), 'Exit 20,800 sats', 'the exit button names the ticked coins still inside their timelock');
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.disabled), false);
+ await page.click('.ark-exit-ask'); await sleep(200);
+ tx = await text();
+ assert.match(tx, /Coins\s*2/); assert.match(tx, /Amount\s*20,800 sats/);
+ assert.match(tx, /If the server vanished today/, 'the exit explainer is on the confirmation');
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200);
+ console.log('✓ a unilateral exit counts only the coin still inside its timelock');
+
+ // opt into keeping the exit: persisted, the page reprices, renewal starts covering
+ await page.select('#ark-custody-select', 'self'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody')), 'self', 'saved with the wallet');
+ tx = await text();
+ assert.match(tx, /Custody choice saved/);
+ assert.match(await renewSheet(), /renews coins automatically once they are close to expiring/);
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /Coins renew in the last hours/);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats per renewal');
+ const calls = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1', 'coin-2']], 'the coin past its timelock is renewed now with the small change; the deep healthy one is left alone');
+ console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing');
+
+ // the exit tier also flattens the deep coin — but not the deep 800-sat one on its own merits
+ await page.evaluate(() => { coopFeature.autoRefreshCalls.length = 0; });
+ await page.select('#ark-custody-select', 'exit'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats near expiry, 0.2% + 300 sats when early');
+ const calls2 = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls2[1], ['refresh', ['coin-1', 'coin-0', 'coin-2']], 'expired + deep (worth it) + small change ride one round');
+ console.log('✓ "Keep my exit cheap" renews the deep coin too, when the fee is under a tenth of it');
+ await page.select('#ark-custody-select', 'custodial'); await sleep(300);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await renewSheet(), /nothing renews on its own/);
+ console.log('✓ the other tiers switch the same way, and back');
+ assert.deepEqual(errors, [], 'no page errors');
+ console.log('\n✅ custody is a choice, made on the Spending page');
+} finally { await browser.close(); server.stop(true); }