Oregami
Repositories/oxedyne/daimond

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.
14import { open, shot, errors } from './harness.mjs';
15
16const ok = [], bad = [];
17const check = (name, pass, detail) => {
18 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
19 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
20};
21const 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
30const s = await open({ name: 'spend', signIn: true, connect: true });
31const { page } = s;
32
33await 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.
39await page.waitForFunction(
40 () => window.DaimondGateway && DaimondGateway.state().authed,
41 null, { timeout: 12000 }).catch(() => {});
42
43try {
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
268console.log(`\n${ok.length} passed, ${bad.length} failed`);
269await s.close();
270process.exit(bad.length ? 1 : 0);