oxedyne/daimond/dev/verify_spend.mjs
14.0 KiB, 1 run
created by r2519314175:685, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_spend.mjs — the Spending view shows where the money goes: inference |
| 2 | // (the client per-turn ledger) and credits (the gateway ledger), each as a |
| 3 | // graph, a breakdown and a table. |
| 4 | // |
| 5 | // Drives the real panel (spend.js) with a seeded client ledger and against the REAL |
| 6 | // gateway (/api/ledger), so it needs the dev stack up: the app (DAIMOND_PORT, default |
| 7 | // 8777) and the gateway on :9002. |
| 8 | // |
| 9 | // 1. The panel opens and both sections render, no console errors. |
| 10 | // 2. Inference: a seeded ledger draws bars and a per-model table; the headline |
| 11 | // is non-zero; the Week/Month toggle re-renders. |
| 12 | // 3. /api/ledger answers ok with an entries array, every entry categorised. |
| 13 | // 4. Credits: an authed account shows a balance; the header meter opens the panel. |
| 14 | import { open, shot, errors } from './harness.mjs'; |
| 15 | |
| 16 | const ok = [], bad = []; |
| 17 | const check = (name, pass, detail) => { |
| 18 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 19 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 20 | }; |
| 21 | const until = async (page, fn, arg, ms = 6000) => { |
| 22 | const t0 = Date.now(); |
| 23 | for (;;) { |
| 24 | try { if (await page.evaluate(fn, arg)) return true; } catch (e) {} |
| 25 | if (Date.now() - t0 > ms) return false; |
| 26 | await new Promise(r => setTimeout(r, 60)); |
| 27 | } |
| 28 | }; |
| 29 | |
| 30 | const s = await open({ name: 'spend', signIn: true, connect: true }); |
| 31 | const { page } = s; |
| 32 | |
| 33 | await page.waitForFunction( |
| 34 | () => !!window.DaimondSpend && !!window.DaimondLedger && !!window.DaimondPanels, |
| 35 | null, { timeout: 12000 }).catch(() => {}); |
| 36 | |
| 37 | // Let the gateway bootstrap settle before driving anything, so the credit side |
| 38 | // reads a real session and the sign-in flow's pre-auth calls are done. |
| 39 | await page.waitForFunction( |
| 40 | () => window.DaimondGateway && DaimondGateway.state().authed, |
| 41 | null, { timeout: 12000 }).catch(() => {}); |
| 42 | |
| 43 | try { |
| 44 | check('DaimondSpend is present', await page.evaluate(() => !!window.DaimondSpend)); |
| 45 | |
| 46 | // ── Seed the client inference ledger: two models over several days. The |
| 47 | // stored `u` (USD) is read directly by the view, so figures are deterministic |
| 48 | // without leaning on the price table. ──────────────────────────────────── |
| 49 | const seedJson = await page.evaluate(() => { |
| 50 | const DAY = 86400000, now = Date.now(); |
| 51 | const e = (daysAgo, m, u) => ({ t: now - daysAgo * DAY, m, p: 1200, c: 400, ca: 0, u, e: false }); |
| 52 | const seed = [ |
| 53 | e(0, 'mock/fast', 0.012), e(0, 'anthropic/claude-x', 0.21), |
| 54 | e(1, 'mock/fast', 0.008), |
| 55 | e(3, 'anthropic/claude-x', 0.15), e(3, 'mock/fast', 0.004), |
| 56 | e(10, 'anthropic/claude-x', 0.30), |
| 57 | ]; |
| 58 | localStorage.setItem('daimond-ledger', JSON.stringify(seed)); |
| 59 | return JSON.stringify(seed); // so a later check can put it back |
| 60 | }); |
| 61 | |
| 62 | // Open the panel and wait for it to draw. |
| 63 | await page.evaluate(() => window.DaimondSpend.show()); |
| 64 | await until(page, () => { |
| 65 | const v = document.getElementById('spend-view'); |
| 66 | return v && v.querySelector('.spend-sec'); |
| 67 | }); |
| 68 | |
| 69 | // ── Inference section. ──────────────────────────────────────────────────── |
| 70 | const bars = await page.evaluate(() => document.querySelectorAll('#spend-view .spend-bar').length); |
| 71 | check('inference chart draws bars for spend days', bars > 0, bars + ' bars'); |
| 72 | |
| 73 | const models = await page.evaluate(() => |
| 74 | [...document.querySelectorAll('#spend-view .spend-model')].map(td => td.textContent)); |
| 75 | check('per-model table lists both seeded models', |
| 76 | models.some(m => /claude-x/.test(m)) && models.some(m => /mock\/fast/.test(m)), |
| 77 | models.join(', ')); |
| 78 | |
| 79 | const headline = await page.evaluate(() => |
| 80 | (document.querySelector('#spend-view .spend-stat-val') || {}).textContent || ''); |
| 81 | check('headline shows a non-zero month total', /[1-9]/.test(headline), headline); |
| 82 | |
| 83 | // The Month total should be the sum of everything in the last 30 days |
| 84 | // ($0.704), distinct from the Week total ($0.394); switching the toggle |
| 85 | // must change the chart's bar count (30 → 7 days). |
| 86 | const monthBars = bars; |
| 87 | await page.evaluate(() => { |
| 88 | const btns = [...document.querySelectorAll('#spend-view .spend-toggle-btn')]; |
| 89 | const wk = btns.find(b => /week/i.test(b.textContent)); |
| 90 | if (wk) wk.click(); |
| 91 | }); |
| 92 | await new Promise(r => setTimeout(r, 200)); |
| 93 | const weekBars = await page.evaluate(() => document.querySelectorAll('#spend-view .spend-bar').length); |
| 94 | check('Week/Month toggle re-renders the chart', weekBars !== monthBars, |
| 95 | `month=${monthBars} bars, week=${weekBars} bars`); |
| 96 | |
| 97 | // ── The seq 48 reprice explains itself. ─────────────────────────────────── |
| 98 | // Old estimates were re-priced under the corrected table, which quietly cut |
| 99 | // every total. A user who remembers the larger figure is owed the reason, so |
| 100 | // a period holding repriced turns (`rp`, original guess kept in `u0`) carries |
| 101 | // a provenance line quoting what that period read before -- here |
| 102 | // $0.10 − $0.08 + $0.48 = $0.50. |
| 103 | // |
| 104 | // The redraws here are of the LOCAL side only, so the gateway calls `onOpen` |
| 105 | // makes are stubbed out for the duration. That is not squeamishness about |
| 106 | // traffic: `refreshBalance` publishes `daimond:credits`, and daimond.js |
| 107 | // answers that by refreshing an open Spending panel -- which calls |
| 108 | // `refreshBalance` again. One real refresh with this panel open therefore |
| 109 | // spins, and driving three of them buries the gateway before the checks |
| 110 | // below can reach it. |
| 111 | const repriced = await page.evaluate(async () => { |
| 112 | const g = window.DaimondGateway; |
| 113 | window.__spendReal = { refreshBalance: g.refreshBalance, ledger: g.ledger }; |
| 114 | g.refreshBalance = async () => {}; |
| 115 | g.ledger = async () => []; |
| 116 | const DAY = 86400000, now = Date.now(); |
| 117 | localStorage.setItem('daimond-ledger', JSON.stringify([ |
| 118 | { t: now - 1 * DAY, m: 'mock/fast', p: 1200, c: 400, ca: 0, u: 0.05, u0: 0.30, rp: 1, e: false }, |
| 119 | { t: now - 2 * DAY, m: 'mock/fast', p: 1200, c: 400, ca: 0, u: 0.03, u0: 0.18, rp: 1, e: false }, |
| 120 | { t: now - 3 * DAY, m: 'mock/fast', p: 1200, c: 400, ca: 0, u: 0.02, r: 1, e: false }, |
| 121 | ])); |
| 122 | await window.DaimondSpend.refresh(); |
| 123 | const n = document.querySelector('#spend-view .spend-reprice'); |
| 124 | return { shown: !!n, text: n ? n.textContent : '' }; |
| 125 | }); |
| 126 | check('a repriced period says so', repriced.shown, repriced.text); |
| 127 | check('and quotes what the period read under the old table', |
| 128 | /\$0\.50/.test(repriced.text), repriced.text); |
| 129 | |
| 130 | // A shot of the panel itself, scrolled to the note, for the record. |
| 131 | await page.evaluate(() => { |
| 132 | var v = document.getElementById('spend-view'); |
| 133 | var n = document.querySelector('#spend-view .spend-reprice'); |
| 134 | if (v && n) v.scrollTop += n.getBoundingClientRect().top - v.getBoundingClientRect().top - 90; |
| 135 | }); |
| 136 | await new Promise(r => setTimeout(r, 200)); |
| 137 | await page.locator('#panel-spend') |
| 138 | .screenshot({ path: new URL('./shots/spend-reprice.png', import.meta.url).pathname, timeout: 8000 }) |
| 139 | .catch(() => {}); |
| 140 | |
| 141 | // Nothing repriced in view, nothing to explain: the note ages out with the |
| 142 | // entries rather than lingering as a permanent apology. |
| 143 | const noReprice = await page.evaluate(async () => { |
| 144 | const DAY = 86400000, now = Date.now(); |
| 145 | localStorage.setItem('daimond-ledger', JSON.stringify([ |
| 146 | { t: now - 1 * DAY, m: 'mock/fast', p: 1200, c: 400, ca: 0, u: 0.02, r: 1, e: false }, |
| 147 | { t: now - 2 * DAY, m: 'mock/fast', p: 1200, c: 400, ca: 0, u: 0.04, r: 1, e: false }, |
| 148 | ])); |
| 149 | await window.DaimondSpend.refresh(); |
| 150 | return !document.querySelector('#spend-view .spend-reprice'); |
| 151 | }); |
| 152 | check('a period with no repriced turn shows no such note', noReprice); |
| 153 | |
| 154 | // Put the original seed and the real gateway calls back, so what follows |
| 155 | // sees the ledger and the account it expects. |
| 156 | await page.evaluate(async (seed) => { |
| 157 | localStorage.setItem('daimond-ledger', seed); |
| 158 | await window.DaimondSpend.refresh(); |
| 159 | const g = window.DaimondGateway; |
| 160 | g.refreshBalance = window.__spendReal.refreshBalance; |
| 161 | g.ledger = window.__spendReal.ledger; |
| 162 | }, seedJson); |
| 163 | |
| 164 | // ── /api/ledger contract. ───────────────────────────────────────────────── |
| 165 | const led = await page.evaluate(async () => { |
| 166 | const r = await fetch('/api/ledger', { credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 167 | let j = null; try { j = await r.json(); } catch (e) {} |
| 168 | return { status: r.status, ok: !!(j && j.ok), isArray: Array.isArray(j && j.entries), |
| 169 | categorised: !!(j && Array.isArray(j.entries) && j.entries.every(x => typeof x.category === 'string')), |
| 170 | hasBalance: !!(j && typeof j.credits_minor === 'number') }; |
| 171 | }); |
| 172 | check('/api/ledger returns 200 ok', led.status === 200 && led.ok, 'status ' + led.status); |
| 173 | check('/api/ledger entries is an array', led.isArray); |
| 174 | check('/api/ledger every entry carries a category', led.categorised); |
| 175 | check('/api/ledger reports a numeric balance', led.hasBalance); |
| 176 | |
| 177 | // ── Credits section renders for an authed account. ──────────────────────── |
| 178 | const authed = await page.evaluate(() => window.DaimondGateway.state().authed); |
| 179 | check('gateway session is authed', authed); |
| 180 | const creditsSec = await page.evaluate(() => { |
| 181 | const secs = [...document.querySelectorAll('#spend-view .spend-sec-title')].map(h => h.textContent); |
| 182 | return secs.includes('Credits'); |
| 183 | }); |
| 184 | check('a Credits section is shown', creditsSec); |
| 185 | |
| 186 | // ── Credits breakdown + table render from categorised movements. ────────── |
| 187 | // The fresh test account has no gateway spend, so feed the view a realistic |
| 188 | // categorised ledger and confirm it draws the breakdown and the table. |
| 189 | const creditsRender = await page.evaluate(async () => { |
| 190 | const ns = 1e6; // the view divides ts (ns) by 1e6 for ms |
| 191 | const now = Date.now(); |
| 192 | window.DaimondGateway.ledger = async () => ([ |
| 193 | { ts: now * ns, kind: 'topup', category: 'topup', delta_minor: 2000, balance: 2000, ref: 'evt_1' }, |
| 194 | { ts: (now - 6e4) * ns, kind: 'spend', category: 'web', delta_minor: -35, balance: 1965, ref: 'web:example.com' }, |
| 195 | { ts: (now - 12e4) * ns, kind: 'spend', category: 'mail', delta_minor: -12, balance: 1953, ref: 'mail:me@x.io' }, |
| 196 | { ts: (now - 18e4) * ns, kind: 'spend', category: 'sync', delta_minor: -8, balance: 1945, ref: 'sync:acct' }, |
| 197 | ]); |
| 198 | await window.DaimondSpend.refresh(); |
| 199 | const labels = [...document.querySelectorAll('#spend-view .spend-bd-label')].map(x => x.textContent); |
| 200 | const rows = document.querySelectorAll('#spend-view .spend-table tbody tr').length; |
| 201 | const debits = document.querySelectorAll('#spend-view .spend-table td.debit, #spend-view .spend-table td.credit').length; |
| 202 | return { labels, rows, debits }; |
| 203 | }); |
| 204 | check('credit breakdown lists the spend categories', |
| 205 | ['Web pages', 'Mail', 'Cross-device sync'].every(l => creditsRender.labels.includes(l)), |
| 206 | creditsRender.labels.join(', ')); |
| 207 | check('credit movements table has a row per movement', creditsRender.rows >= 4, creditsRender.rows + ' rows'); |
| 208 | check('movements show signed debit/credit amounts', creditsRender.debits >= 4, creditsRender.debits + ' amounts'); |
| 209 | |
| 210 | // ── The header meter opens the panel. ───────────────────────────────────── |
| 211 | // (Re-render fresh, close the panel, then click the meter.) |
| 212 | const meterOpens = await page.evaluate(async () => { |
| 213 | document.getElementById('spend-view').innerHTML = ''; // prove the click repopulates it |
| 214 | const row = document.getElementById('spend-row'); |
| 215 | if (!row) return 'no-row'; |
| 216 | row.click(); |
| 217 | await new Promise(r => setTimeout(r, 400)); |
| 218 | return document.querySelector('#spend-view .spend-sec') ? 'opened' : 'empty'; |
| 219 | }); |
| 220 | check('header spend meter opens the Spending view', meterOpens === 'opened', meterOpens); |
| 221 | |
| 222 | // ── The Admin > Credits view offers an always-available door, for a |
| 223 | // credits-only user whose header meter (inference-gated) never appears. ──── |
| 224 | const adminDoor = await page.evaluate(async () => { |
| 225 | const b = document.getElementById('credits-see-spend'); |
| 226 | if (!b) return 'no-button'; |
| 227 | document.getElementById('spend-view').innerHTML = ''; |
| 228 | b.click(); |
| 229 | await new Promise(r => setTimeout(r, 400)); |
| 230 | return document.querySelector('#spend-view .spend-sec') ? 'opened' : 'empty'; |
| 231 | }); |
| 232 | check('Admin credits view has a working "see spending" door', adminDoor === 'opened', adminDoor); |
| 233 | |
| 234 | // ── An open panel at rest is quiet. gateway.js announces a balance only |
| 235 | // when it MOVED: an unconditional announce closed a loop (the open panel |
| 236 | // refreshes on the announce, the refresh fetches the balance, the reply |
| 237 | // announces again) that drove the gateway at hundreds of requests a second |
| 238 | // for as long as the panel stayed on screen. The admin door above left the |
| 239 | // view open, which is exactly the state the loop needed; one nudge would |
| 240 | // have restarted it. ──── |
| 241 | { |
| 242 | let rest = 0; |
| 243 | const count = r => { if (/\/api\/(balance|ledger)/.test(r.url())) rest++; }; |
| 244 | page.on('request', count); |
| 245 | await page.evaluate(() => window.DaimondGateway.refreshBalance()); |
| 246 | await new Promise(r => setTimeout(r, 3000)); |
| 247 | page.off('request', count); |
| 248 | check('an open panel at rest does not drive the gateway', |
| 249 | rest <= 3, rest + ' requests in 3s'); |
| 250 | } |
| 251 | |
| 252 | await shot(s, 'spend-view'); |
| 253 | |
| 254 | // Ambient sign-in noise: /api/tools and /api/sync fire during the bootstrap |
| 255 | // race and 401 before the session cookie lands (confirmed in the gateway log, |
| 256 | // and unrelated to this view -- every /api/ledger and /api/balance call the |
| 257 | // spend view makes returns 200). Everything else is a real error. |
| 258 | // A free account's idle sync push is refused with 402 -- cross-device sync is |
| 259 | // a Pro capability -- and the browser logs the failed request. That is the |
| 260 | // product working, not this feature failing, so it is ambient here too. |
| 261 | const errs = errors(s).filter(e => !/status of 401|status of 402/.test(e)); |
| 262 | check('no console errors (other than ambient sign-in 401s and the free-tier sync 402)', |
| 263 | errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 264 | } catch (e) { |
| 265 | check('run completed without throwing', false, e.message); |
| 266 | } |
| 267 | |
| 268 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 269 | await s.close(); |
| 270 | process.exit(bad.length ? 1 : 0); |