diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 14:44:33 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 14:44:33 -0700 |
| commit | 4fcfbef28ee9f53425f552b69e99422096d8dfcb (patch) | |
| tree | acbd6212ca8e8b17b667b1e768d0fd6bbb183ff9 /tools/history-export-test.js | |
| parent | 73030636812a20fd025b6c558695b50224bdfb0e (diff) | |
| download | coinosv3-4fcfbef28ee9f53425f552b69e99422096d8dfcb.tar.gz coinosv3-4fcfbef28ee9f53425f552b69e99422096d8dfcb.zip | |
Settings → Wallet → Transaction history: Export CSV — every payment in Spending and Savings as a line of a CSV file, oldest first: date (UTC), account, type, rail (Lightning, Ark, on-chain), what was paid, its fee, the change to the balance and its worth in your currency at the time, status, the transaction or payment hash, who it went to, the invoice. A failed payment is left out; a renewal is its fee.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'tools/history-export-test.js')
| -rw-r--r-- | tools/history-export-test.js | 81 |
1 files changed, 81 insertions, 0 deletions
diff --git a/tools/history-export-test.js b/tools/history-export-test.js new file mode 100644 index 0000000..f7fff82 --- /dev/null +++ b/tools/history-export-test.js @@ -0,0 +1,81 @@ +// Settings → Wallet → Export CSV: every payment in Spending (seeded Ark +// movements) as a line of a CSV file, oldest first, with what was paid, the +// fee beside it and the change to the balance; a failed payment is left out. +// Run: bun tools/history-export-test.js (NO_BUILD=1 to reuse dist/) +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const ns = (url) => { let h = 5381; for (const c of url) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0; return h.toString(36); }; +const T0 = Date.UTC(2026, 9, 1, 12, 0, 0); +const movements = [ + { id: 'm1', type: 'ln-receive', status: 'complete', amountSat: 5000, invoice: 'lnbcrt50u1test', ts: T0 }, + { id: 'm2', type: 'ln-send', status: 'complete', amountSat: 1000, detail: 'fee 3 sat', paymentHash: 'ab'.repeat(32), invoice: 'lnbcrt10u1test', ts: T0 + 60_000 }, + { id: 'm3', type: 'send', status: 'complete', amountSat: 700, to: 'tark1someone', vtxoId: 'cd'.repeat(32) + ':0', ts: T0 + 120_000 }, + { id: 'm4', type: 'send', status: 'failed', amountSat: 999, detail: 'nope', ts: T0 + 180_000 }, + { id: 'm5', type: 'refresh', status: 'complete', amountSat: 3297, feeSat: 300, manual: true, txid: 'ef'.repeat(32), ts: T0 + 240_000 }, +]; + +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); +const server = Bun.serve({ port: 0, fetch: async (req) => { + const path = new URL(req.url).pathname; + const f = Bun.file('dist' + path); + return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +// the file the page hands the browser to save +await page.evaluateOnNewDocument(() => { + const make = URL.createObjectURL; + URL.createObjectURL = (b) => { b.text().then((x) => { window.__csv = x; window.__csvType = b.type; }); return make(b); }; + const click = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function () { if (this.download) { window.__csvName = this.download; return; } return click.call(this); }; +}); +const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive'); + // Spending's history, as the Ark manager stores it (moved into IndexedDB at the next boot) + const key = cacheKeyFor(mn + '\n') + ':ark:' + ns('http://localhost:3535'); + await page.evaluate(([k, v]) => localStorage.setItem(k, JSON.stringify(v)), [key, { v: 1, _rev: 999, vtxos: [], actions: [], movements, scheduled: [] }]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive'); + await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500); + await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(500); + check('Settings → Wallet has the export', await waitText('transaction history', 5000) && !!(await page.$('.history-export'))); + await page.evaluate(() => document.querySelector('.history-export').click()); + for (let i = 0; i < 20 && !(await page.evaluate(() => window.__csv)); i++) await sleep(100); + const { csv, name, type } = await page.evaluate(() => ({ csv: window.__csv, name: window.__csvName, type: window.__csvType })); + check('a .csv file is saved, named for today', /^coinos-history-\d{4}-\d{2}-\d{2}\.csv$/.test(name || '') && /text\/csv/.test(type || ''), name); + const rows = (csv || '').trim().split('\r\n').map((l) => l.split(',')); + console.log((csv || '').trim().split('\r\n').map((l) => ' ' + l).join('\n')); + const head = rows[0] || []; + const col = (r, n) => r[head.indexOf(n)]; + check('a header names the columns', head[0] === 'Date (UTC)' && head.includes('Net (sats)') && head.includes('Fee (sats)')); + const body = rows.slice(1); + check('...one line per payment, the failed one left out, oldest first', body.length === 4 && body.map((r) => col(r, 'Type')).join() === 'ln-receive,ln-send,send,refresh', body.map((r) => col(r, 'Type')).join()); + check('...dated in UTC', col(body[0], 'Date (UTC)') === '2026-10-01 12:00:00'); + check('a receive: + its amount, on Lightning, in Spending', col(body[0], 'Account') === 'Spending' && col(body[0], 'Rail') === 'lightning' && col(body[0], 'Net (sats)') === '5000'); + check('a Lightning payment: what was paid, its fee, both off the balance', col(body[1], 'Amount (sats)') === '-1000' && col(body[1], 'Fee (sats)') === '3' && col(body[1], 'Net (sats)') === '-1003' && col(body[1], 'Transaction / payment hash') === 'ab'.repeat(32)); + check('an Ark payment names who it went to', col(body[2], 'Net (sats)') === '-700' && col(body[2], 'To') === 'tark1someone' && col(body[2], 'Rail') === 'ark'); + check('a renewal costs its fee alone', col(body[3], 'Amount (sats)') === '0' && col(body[3], 'Net (sats)') === '-300'); + check('the lines add up to the balance they leave', body.reduce((s, r) => s + Number(col(r, 'Net (sats)')), 0) === 5000 - 1003 - 700 - 300); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { + await browser.close(); + server.stop(true); +} +console.log(ok ? '\n✅ history exports as CSV' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
