diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/call-test.js | 12 | ||||
| -rw-r--r-- | tools/history-export-test.js | 81 | ||||
| -rw-r--r-- | tools/live-stream-test.js | 19 |
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(); }); |
