aboutsummaryrefslogtreecommitdiffstats
path: root/static
ModeNameSize
-rw-r--r--favicon.svg1302logstatsplainblame
d---------fonts / Public_Sans38logstatsplain
-rw-r--r--home.png260144logstatsplainblame
-rw-r--r--icon-192x192.pngpre { line-height: 125%; } td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } .highlight .hll { background-color: #ffffcc } .highlight .c { color: #888 } /* Comment */ .highlight .err { color: #A61717; background-color: #E3D2D2 } /* Error */ .highlight .k { color: #080; font-weight: bold } /* Keyword */ .highlight .ch { color: #888 } /* Comment.Hashbang */ .highlight .cm { color: #888 } /* Comment.Multiline */ .highlight .cp { color: #C00; font-weight: bold } /* Comment.Preproc */ .highlight .cpf { color: #888 } /* Comment.PreprocFile */ .highlight .c1 { color: #888 } /* Comment.Single */ .highlight .cs { color: #C00; font-weight: bold; background-color: #FFF0F0 } /* Comment.Special */ .highlight .gd { color: #000; background-color: #FDD } /* Generic.Deleted */ .highlight .ge { font-style: italic } /* Generic.Emph */ .highlight .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ .highlight .gr { color: #A00 } /* Generic.Error */ .highlight .gh { color: #333 } /* Generic.Heading */ .highlight .gi { color: #000; background-color: #DFD } /* Generic.Inserted */ .highlight .go { color: #888 } /* Generic.Output */ .highlight .gp { color: #555 } /* Generic.Prompt */ .highlight .gs { font-weight: bold } /* Generic.Strong */ .highlight .gu { color: #666 } /* Generic.Subheading */ .highlight .gt { color: #A00 } /* Generic.Traceback */ .highlight .kc { color: #080; font-weight: bold } /* Keyword.Constant */ .highlight .kd { color: #080; font-weight: bold } /* Keyword.Declaration */ .highlight .kn { color: #080; font-weight: bold } /* Keyword.Namespace */ .highlight .kp { color: #080 } /* Keyword.Pseudo */ .highlight .kr { color: #080; font-weight: bold } /* Keyword.Reserved */ .highlight .kt { color: #888; font-weight: bold } /* Keyword.Type */ .highlight .m { color: #00D; font-weight: bold } /* Literal.Number */ .highlight .s { color: #D20; background-color: #FFF0F0 } /* Literal.String */ .highlight .na { color: #369 } /* Name.Attribute */ .highlight .nb { color: #038 } /* Name.Builtin */ .highlight .nc { color: #B06; font-weight: bold } /* Name.Class */ .highlight .no { color: #036; font-weight: bold } /* Name.Constant */ .highlight .nd { color: #555 } /* Name.Decorator */ .highlight .ne { color: #B06; font-weight: bold } /* Name.Exception */ .highlight .nf { color: #06B; font-weight: bold } /* Name.Function */ .highlight .nl { color: #369; font-style: italic } /* Name.Label */ .highlight .nn { color: #B06; font-weight: bold } /* Name.Namespace */ .highlight .py { color: #369; font-weight: bold } /* Name.Property */ .highlight .nt { color: #B06; font-weight: bold } /* Name.Tag */ .highlight .nv { color: #369 } /* Name.Variable */ .highlight .ow { color: #080 } /* Operator.Word */ .highlight .w { color: #BBB } /* Text.Whitespace */ .highlight .mb { color: #00D; font-weight: bold } /* Literal.Number.Bin */ .highlight .mf { color: #00D; font-weight: bold } /* Literal.Number.Float */ .highlight .mh { color: #00D; font-weight: bold } /* Literal.Number.Hex */ .highlight .mi { color: #00D; font-weight: bold } /* Literal.Number.Integer */ .highlight .mo { color: #00D; font-weight: bold } /* Literal.Number.Oct */ .highlight .sa { color: #D20; background-color: #FFF0F0 } /* Literal.String.Affix */ .highlight .sb { color: #D20; background-color: #FFF0F0 } /* Literal.String.Backtick */ .highlight .sc { color: #D20; background-color: #FFF0F0 } /* Literal.String.Char */ .highlight .dl { color: #D20; background-color: #FFF0F0 } /* Literal.String.Delimiter */ .highlight .sd { color: #D20; background-color: #FFF0F0 } /* Literal.String.Doc */ .highlight .s2 { color: #D20; background-color: #FFF0F0 } /* Literal.String.Double */ .highlight .se { color: #04D; background-color: #FFF0F0 } /* Literal.String.Escape */ .highlight .sh { color: #D20; background-color: #FFF0F0 } /* Literal.String.Heredoc */ .highlight .si { color: #33B; background-color: #FFF0F0 } /* Literal.String.Interpol */ .highlight .sx { color: #2B2; background-color: #F0FFF0 } /* Literal.String.Other */ .highlight .sr { color: #080; background-color: #FFF0FF } /* Literal.String.Regex */ .highlight .s1 { color: #D20; background-color: #FFF0F0 } /* Literal.String.Single */ .highlight .ss { color: #A60; background-color: #FFF0F0 } /* Literal.String.Symbol */ .highlight .bp { color: #038 } /* Name.Builtin.Pseudo */ .highlight .fm { color: #06B; font-weight: bold } /* Name.Function.Magic */ .highlight .vc { color: #369 } /* Name.Variable.Class */ .highlight .vg { color: #D70 } /* Name.Variable.Global */ .highlight .vi { color: #33B } /* Name.Variable.Instance */ .highlight .vm { color: #369 } /* Name.Variable.Magic */ .highlight .il { color: #00D; font-weight: bold } /* Literal.Number.Integer.Long */
// 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.js';

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');