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
|
// Point of sale: amount → tip prompt → one invoice for the total → paid, with
// the bill and the tip counted apart. The Ark side is faked: no relays, no
// server, no money. Run: bun tools/pos-test.js
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer-core';
const app = await Bun.file('src/app.js').text();
const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
const entry = `
import { posFeature } from './src/features/pos.js';
${domHelper}
const state = {};
const ui = { screen: 'wallet' };
let watch = null, cancelled = [], invoices = 0;
const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = JSON.parse(JSON.stringify(v)); }, _cacheKey: () => window.__walletKey || 'w1' };
const ctx = {
h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m), copy: (x) => (window.copied = x),
fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'),
parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) ? Math.round(n) : null; }, getUnit: () => window.__unit || 'sats',
brandHeader: () => null,
hook(name, ...a) {
if (name === 'arkReady') return true;
if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); }
if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; }
if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; }
if (name === 'arkLnCancel') { cancelled.push(a[0]); return Promise.resolve(true); }
if (name === 'arkMovements') return window.__movements || [];
return null;
},
};
const feature = posFeature(ctx); window.__feature = feature;
function render() { document.querySelector('#app').replaceChildren(feature.screenView() || h('div', { id: 'closed' }, 'closed')); }
window.test = { open: () => { feature.openPos(); }, init: () => { feature.init(); render(); },
cards: () => { document.querySelector('#app').replaceChildren(...feature.posSettingsCards()); }, settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, get sales() { return (state.pos || {}).sales || []; }, ui };
render();
`;
const bundle = await Bun.build({
entrypoints: ['pos-test-entry'], target: 'browser',
plugins: [{ name: 'pos-test', setup(build) {
build.onResolve({ filter: /^pos-test-entry$/ }, () => ({ path: 'entry', namespace: 'pos-test' }));
build.onLoad({ filter: /.*/, namespace: 'pos-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
} }],
});
assert(bundle.success, bundle.logs.join('\n'));
const css = await Bun.file('src/style.css').text();
const js = await bundle.outputs[0].text();
const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`;
const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => (n.querySelector('.pos-tip-label') || n).textContent.trim() === x); if (!b) return false; b.click(); return true; }, label);
// the harness repaints by replacing nodes (the app morphs in place), so a
// field is filled in one go rather than keystroke by keystroke
const type = (sel, v) => page.evaluate(([s, val]) => { const i = document.querySelector(s); i.value = val; i.dispatchEvent(new Event('input', { bubbles: true })); }, [sel, v]);
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(server.url.href);
await page.waitForSelector('#closed');
await page.evaluate(() => test.open()); await pause(50);
check('the point of sale opens on the amount screen', /Point of sale/.test(await text()) && !!(await page.$('.pos-amount')));
check('the amount reads across a counter', await page.$eval('.pos-amount', (i) => parseFloat(getComputedStyle(i).fontSize)) >= 40);
await click('Charge'); await pause(50);
check('charging nothing asks for an amount', /Enter an amount first/.test(await text()));
for (const k of ['1', '0', '0', '0']) await click(k);
check('the on-screen pad fills the amount field', await page.$eval('.pos-amount', (i) => i.value) === '1000');
const hitDel = await click('\u232b'); const afterDel = await page.$eval('.pos-amount', (i) => i.value); await click('0');
check('backspace edits the same field', await page.$eval('.pos-amount', (i) => i.value) === '1000', JSON.stringify({ hitDel, afterDel }));
await type('input[placeholder="Note (optional)"]', 'Table 4');
await click('Charge'); await pause(50);
check('the tip prompt shows the bill and the tip choices', /Your bill 1,000 sats/.test(await text()) && /10%.*15%.*20%.*Other.*No tip/.test(await text()));
await click('15%'); await pause(50);
check('a percentage shows the total', /Total 1,150 sats/.test(await text()));
await click('Continue'); await pause(100);
check('one invoice is made for bill plus tip', await page.evaluate(() => test.watch === 'ln1') && /1,150 sats/.test(await text()) && /Bill 1,000 \+ tip 150 sats/.test(await text()) && !!(await page.$('.pos-qr svg')));
await click('Copy');
check('copy hands over the invoice', await page.evaluate(() => window.copied === 'lnbc1150n1testinvoice1'));
await page.evaluate(() => test.settle(true)); await pause(50);
check('settlement shows Paid with the breakdown', /Paid/.test(await text()) && /Bill 1,000 \+ tip 150 sats/.test(await text()));
await click('New sale'); await pause(50);
check('the ledger keeps the bill and the tip apart', /Bills 1,000 sats/.test(await text()) && /Tips 150 sats/.test(await text()) && /Total 1,150 sats/.test(await text()) && /Table 4/.test(await text()));
await page.evaluate(() => { [...document.querySelectorAll('.pos-sale')].find((r) => /Table 4/.test(r.textContent)).click(); }); await pause(50);
check('a sale with no payment record yet says so', await page.evaluate(() => (window.toasts || []).at(-1) === 'No payment record for this sale yet'));
await page.evaluate(() => { window.__movements = [{ id: 'mv1', type: 'ln-receive', status: 'complete', amountSat: 1150, invoice: 'lnbc1150n1testinvoice1', ts: Date.now() }]; });
await page.evaluate(() => { [...document.querySelectorAll('.pos-sale')].find((r) => /Table 4/.test(r.textContent)).click(); }); await pause(50);
check('a paid sale opens the payment detail by its movement', await page.evaluate(() => test.ui.arkMoveDetail === 'mv1'));
check('...and the detail knows the bill, the tip and the note', await page.evaluate(() => { const lines = window.__feature.arkMoveDetailExtra(window.__movements[0], null).filter(Boolean).map((n) => n.textContent); return JSON.stringify(lines); }) === JSON.stringify(['Bills1,000 sats', 'Tips150 sats (15%)', 'NoteTable 4']));
await page.evaluate(() => { test.ui.arkMoveDetail = null; });
console.log('\n[a custom tip, then a cancelled sale]');
await type('.pos-amount', '2000'); await click('Charge'); await pause(50);
await click('Other'); await pause(50);
await click('5'); await click('0'); await pause(50);
check('a custom tip adds what was typed', /Total 2,050 sats/.test(await text()));
await click('Continue'); await pause(100);
await click('Cancel'); await pause(50);
check('cancelling voids the sale and the invoice', await page.evaluate(() => test.cancelled.includes('ln2') && test.sales.find((s) => s.billSat === 2000).status === 'void') && /cancelled/.test(await text()));
check('a cancelled sale does not count', /Bills 1,000 sats/.test(await text()) && /Tips 150 sats/.test(await text()));
console.log('\n[no tip prompt]');
await page.click('input[type=checkbox]'); await pause(50);
await type('.pos-amount', '300'); await click('Charge'); await pause(100);
check('with the prompt off, Charge goes straight to the invoice', /Scan to pay/.test(await text()) && /300 sats/.test(await text()) && await page.evaluate(() => test.watch === 'ln3'));
await page.evaluate(() => test.settle(false)); await pause(50);
check('an expired invoice returns to the amount screen and says so', /That invoice expired/.test(await text()));
console.log('\n[a till in dollars]');
await page.evaluate(() => { window.__unit = 'fiat'; test.ui.pos = null; test.open(); }); await pause(50);
await click('5');
check('one digit is cents', await page.$eval('.pos-amount', (i) => i.value) === '0.05');
await click('\u232b'); for (const k of ['2', '7', '7']) await click(k);
check('digits shift in from the right', await page.$eval('.pos-amount', (i) => i.value) === '2.77');
await click('\u232b'); await click('\u232b'); await click('\u232b'); for (const k of ['5', '5', '8', '8']) await click(k);
check('...to any length', await page.$eval('.pos-amount', (i) => i.value) === '55.88');
await click('\u232b'); await click('\u232b'); await click('00');
check('the dot key is 00 in a currency', await page.$eval('.pos-amount', (i) => i.value) === '55.00');
await page.evaluate(() => { window.__unit = 'sats'; });
await page.evaluate(() => { test.ui.pos = null; test.ui.posAtBoot = true; test.init(); }); await pause(50);
check('the /pos link opens the till once a wallet is up', !!(await page.$('.pos-amount')));
console.log('\n[start in point of sale belongs to one wallet]');
const boot = async (key) => { await page.evaluate((k) => { window.__walletKey = k; test.ui.pos = null; test.init(); }, key); await pause(50); return !!(await page.$('.pos-amount')); };
await page.evaluate(() => { localStorage.setItem('btc-wallet-pos-mode', '1'); window.__walletKey = 'owner'; test.cards(); }); await pause(50);
await page.evaluate(() => { const c = [...document.querySelectorAll('label')].find((l) => /Start in point of sale/.test(l.textContent)).querySelector('input'); if (!c.checked) c.click(); }); await pause(50);
check("the old device-wide flag is dropped on start (it can't say whose it was)", await page.evaluate(() => (test.ui.pos = null, true)) && (await boot('owner')) && await page.evaluate(() => localStorage.getItem('btc-wallet-pos-mode') === null));
check('another wallet on the same device does not start in point of sale', !(await boot('someone-else')));
check('the owner still does, after anyone else has been in', await boot('owner'));
check('no browser errors', errors.length === 0);
} finally { await browser.close(); server.stop(true); }
console.log('\n✅ point of sale');
|