aboutsummaryrefslogtreecommitdiffstats
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/call-test.js12
-rw-r--r--tools/history-export-test.js81
-rw-r--r--tools/live-stream-test.js19
3 files changed, 106 insertions, 6 deletions
diff --git a/tools/call-test.js b/tools/call-test.js
index 7e9a99c..5bfa38c 100644
--- a/tools/call-test.js
+++ b/tools/call-test.js
@@ -14,6 +14,7 @@ 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 PORT = 5305;
+const SLOW = +(process.env.SLOW || 0);
if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
// a small relay: keeps what isn't ephemeral, sends each event to the subscriptions it matches
@@ -25,7 +26,9 @@ const server = Bun.serve({ port: PORT,
fetch: async (req, srv) => {
const path = new URL(req.url).pathname;
if (path === '/relay') return srv.upgrade(req) ? undefined : new Response('ws', { status: 426 });
- if (path === '/live/turn') return Response.json({ iceServers: [], ttl: 600 }); // two tabs on one machine: no TURN needed
+ // two tabs on one machine: no TURN needed (SLOW=ms makes it, and the relay
+ // lookups, take as long as they do out there)
+ if (path === '/live/turn') { if (SLOW) await sleep(SLOW); return Response.json({ iceServers: [], ttl: 600 }); }
const f = Bun.file('dist' + path);
return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
},
@@ -39,6 +42,7 @@ const server = Bun.serve({ port: PORT,
if (!(e.kind >= 20000 && e.kind < 30000)) stored.push(e);
for (const s of subs) if (s.ws.readyState === 1 && s.filters.some((f) => match({ ...f, since: undefined }, e))) s.ws.send(JSON.stringify(['EVENT', s.id, e]));
} else if (m[0] === 'REQ') {
+ if (SLOW && m.slice(2).some((f) => (f.kinds || []).includes(10050))) { setTimeout(() => ws.send(JSON.stringify(['EOSE', m[1]])), SLOW); return; }
const s = { ws, id: m[1], filters: m.slice(2) };
ws.data.subs.set(m[1], s); subs.add(s);
for (const e of stored) if (s.filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
@@ -106,8 +110,12 @@ try {
check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000));
check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000));
check('everything went as ephemeral gift wraps', kindsSeen.length > 0 && !kindsSeen.some((k) => k >= 25050 && k <= 25055) && kindsSeen.includes(21059));
+ if (process.env.RING_WAIT) await sleep(+process.env.RING_WAIT); // a person reaching for the phone
+ const tAccept = Date.now();
await B.page.evaluate(() => document.querySelector('.call-accept').click());
- check('answered: both connected, the clock running', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
+ await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000);
+ const answerMs = Date.now() - tAccept;
+ check('answered: both connected, the clock running (' + answerMs + ' ms from Answer)', await waitFor(A.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 20000) && await waitFor(B.page, () => /^\d+:\d\d$/.test(document.querySelector('.call-overlay .call-status')?.textContent || ''), 5000));
await sleep(1500);
const ma = await media(A.page), mb = await media(B.page);
check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb }));
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);
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
index 4b8172d..88d2b17 100644
--- a/tools/live-stream-test.js
+++ b/tools/live-stream-test.js
@@ -140,19 +140,28 @@ try {
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
console.log('\n[the feed]');
- check('a followed host\'s stream shows above the posts', await waitFor(() => !!document.querySelector('.live-strip .live-tile'), 15000));
- const tile = await page.evaluate(() => [...document.querySelectorAll('.live-strip .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
+ check('the Live chip sits after Videos, a red dot on it while a followed host streams', await waitFor(() => {
+ const modes = [...document.querySelectorAll('.media-mode')].map((b) => b.dataset.mode);
+ return modes.indexOf('live') === modes.indexOf('videos') + 1 && !!document.querySelector('.media-mode[data-mode="live"] .live-dot');
+ }, 15000));
+ check('...and no streams above the posts', !(await page.evaluate(() => document.querySelector('.live-grid, .live-strip'))));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
+ check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid .live-tile'), 15000));
+ const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile));
check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream'))));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000));
check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000));
await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click());
check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000));
await page.goBack();
- await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-strip'), 5000);
+ await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
+ await waitFor(() => !!document.querySelector('.live-grid .live-tile'), 5000);
console.log('\n[the stream\'s page]');
- await page.evaluate(() => document.querySelector('.live-strip .live-tile').click());
+ await page.evaluate(() => document.querySelector('.live-grid .live-tile').click());
check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname));
check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; })));
check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM));
@@ -170,6 +179,8 @@ try {
await page.goBack();
check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click());
+ await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000);
await page.evaluate(() => document.querySelector('.notes-feed .live-card').click());
check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000));
await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); });