mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
Two passes of validators across CLI accuracy, dashboard UX, menubar Swift, performance, security, and end-to-end smoke tests on real session data. Data-correctness fixes: - parseLocalDate rejects month/day overflow. JS Date silently rolled Feb 31 to Mar 3, so --from 2026-02-31 --to 2026-03-15 quietly dropped sessions on Feb 28 - Mar 2. Now throws "Invalid date" with a clear reason. Leap-day case covered (2024-02-29 valid, 2025-02-29 rejected). - CSV/JSON exports use the active currency's natural decimal places. The previous round2 helper produced ¥412.37 in CSV while the dashboard rendered ¥412 — finance teams comparing the two surfaces saw a discrepancy. New roundForActiveCurrency consults Intl.NumberFormat for the right precision (0 for JPY/KRW/CLP, 2 for USD/EUR, etc). - Copilot toolRequests is Array.isArray-guarded in both modern and legacy event branches. Previously a corrupt session with toolRequests=null or a string aborted the whole file's parse loop and silently dropped every legitimate call after it. - Codex token_count dedup uses a null sentinel for prevCumulativeTotal so the first event is never confused with a duplicate. Sessions that emit only last_token_usage (no total_token_usage) report cumulativeTotal=0 on every event; with the previous 0-initialized prev, the first event matched the dedup guard and was dropped. - LiteLLM pricing values are clamped to [0, 1] per token via safePerTokenRate. Defense in depth against a tampered upstream JSON shipping negative or absurdly large per-token costs that would otherwise propagate into all cost totals. Performance: - Cursor SQLite parse no longer pegs at minutes on multi-GB DBs. Two changes: per-conversation user-message buffer uses an index pointer instead of Array.shift() (which was O(n) per call); and a real ROWID cutoff via subquery limits the scan to the most recent 250k bubbles with a stderr warning so power users get a partial report rather than a stalled CLI. - Spawned codeburn CLI subprocesses are terminated when the calling Task is cancelled. Without this, rapid period/provider tab clicks in the menubar cancelled the Task but left the subprocess running to completion, piling up zombie processes. UX: - Dashboard period switch flips to loading and clears projects synchronously before reloadData runs, eliminating the frame where the new period label rendered over the old period's projects. - Optimize findings tab paginates 3-at-a-time with j/k scroll. With 4 new detectors plus 7 originals, 8-10 findings * 6 lines was scrolling the StatusBar off the alt buffer top. - Custom --from/--to ranges hide the period tab strip and disable the 1-5 / arrow keys so a stray period press no longer abandons the user's explicit range. A "Custom range: X to Y" banner replaces the tab strip. - OpenCode storage-format warning is per-table-set, rate-limited to once per process, and points the user at OpenCode's migration step or the issue tracker. The previous all-or-nothing check fired the generic "format not recognized" string for any schema mismatch. Menubar / OAuth: - Both Claude and Codex bootstrap (Reconnect button) now honour the usageBlockedUntil 429 backoff that refreshIfBootstrapped respects. Spamming Reconnect during sustained rate-limit windows previously hammered the upstream endpoint on every click. - Codex Retry-After HTTP header is parsed (delta-seconds plus IMF-fixdate fallback) so we don't over-back-off when ChatGPT tells us a shorter window than our 5-minute floor. - Both credential cache files are written via SafeFile.write (O_CREAT | O_EXCL | O_NOFOLLOW with explicit 0600) so there is no race window where the temp file briefly exists at default umask, and a symlink at the destination cannot redirect the write. Reads now route through SafeFile.read with a 64 KiB cap, closing the symlink-follow gap on Data(contentsOf:). CI signal: - TypeScript strict typecheck (tsc --noEmit) is now zero errors. The six errors in src/providers/copilot.ts came from a discriminated-union catch-all branch whose `data: Record<string, unknown>` shape TS picked over the specific event branches when narrowing on `type`. Removed the catch-all; runtime falls through unknown event types via the existing if/else chain. Tests added: 16 new (now 555 total) - date-range-filter: month/day/year overflow rejection, leap-day correctness - currency-rounding: convertCost no-rounding contract, roundForActiveCurrency for USD/JPY/KRW/EUR - providers/copilot: malformed toolRequests does not abort the parse - providers/cursor-bubble-dedup: re-parse after token mutation does not double-count, single parse yields one call per bubble - providers/codex: first event with cumulativeTotal=0 not dropped, consecutive zero-cumulative duplicates still deduped
104 lines
4 KiB
TypeScript
104 lines
4 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { convertCost, roundForActiveCurrency, getFractionDigits } from '../src/currency.js'
|
|
import { CurrencyState } from '../src/currency.js'
|
|
import * as currencyMod from '../src/currency.js'
|
|
|
|
// We poke the module-level state directly via switchCurrency for these tests.
|
|
// Each test restores USD afterwards so it doesn't bleed.
|
|
async function setActive(code: string, rate: number): Promise<void> {
|
|
// switchCurrency does network + persistence; for unit tests we set the
|
|
// active state directly via the module's internal state. Since the module
|
|
// doesn't expose a setter, we go through getCurrency()'s state and patch.
|
|
// Instead use the public switchCurrency only when offline: nope, just
|
|
// exploit the fact that the module exports `getCurrency` which returns a
|
|
// ref. We can't easily mock fetch. So we test only convertCost (which uses
|
|
// active.rate) and rounding helpers — both pure functions of the state.
|
|
const state = currencyMod.getCurrency()
|
|
// @ts-expect-error — directly mutating for test
|
|
state.code = code
|
|
// @ts-expect-error
|
|
state.rate = rate
|
|
// @ts-expect-error
|
|
state.symbol = code
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await setActive('USD', 1)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await setActive('USD', 1)
|
|
})
|
|
|
|
describe('convertCost — no rounding contract', () => {
|
|
it('returns unrounded float for USD (rate=1)', () => {
|
|
expect(convertCost(1.234567)).toBe(1.234567)
|
|
expect(convertCost(0.001)).toBe(0.001)
|
|
})
|
|
|
|
it('returns unrounded float for non-USD currencies', async () => {
|
|
await setActive('JPY', 150)
|
|
// 1 USD * 150 = 150, but a fractional input must NOT be rounded by convertCost.
|
|
expect(convertCost(0.123456)).toBeCloseTo(18.5184, 4)
|
|
expect(convertCost(1.5)).toBe(225)
|
|
})
|
|
|
|
it('rounding is the caller\'s responsibility (display vs export)', async () => {
|
|
// Regression guard: previously convertCost did its own rounding which
|
|
// produced ¥412.37 in CSV exports while the dashboard rendered ¥412.
|
|
// Confirm we now return the raw value and the caller decides.
|
|
await setActive('JPY', 150)
|
|
const raw = convertCost(2.7491)
|
|
expect(raw).toBe(412.365) // unrounded
|
|
expect(roundForActiveCurrency(raw)).toBe(412) // currency-aware rounding for export
|
|
})
|
|
})
|
|
|
|
describe('roundForActiveCurrency', () => {
|
|
it('USD rounds to 2 decimals', async () => {
|
|
await setActive('USD', 1)
|
|
expect(roundForActiveCurrency(1.2345)).toBe(1.23)
|
|
expect(roundForActiveCurrency(1.235)).toBeCloseTo(1.24, 2)
|
|
expect(roundForActiveCurrency(0.005)).toBe(0.01)
|
|
})
|
|
|
|
it('JPY rounds to whole numbers', async () => {
|
|
await setActive('JPY', 150)
|
|
expect(roundForActiveCurrency(412.37)).toBe(412)
|
|
expect(roundForActiveCurrency(412.5)).toBe(413)
|
|
expect(roundForActiveCurrency(0.4)).toBe(0)
|
|
})
|
|
|
|
it('KRW rounds to whole numbers', async () => {
|
|
await setActive('KRW', 1300)
|
|
expect(roundForActiveCurrency(15999.7)).toBe(16000)
|
|
})
|
|
|
|
it('EUR rounds to 2 decimals like USD', async () => {
|
|
await setActive('EUR', 0.92)
|
|
expect(roundForActiveCurrency(1.2345)).toBe(1.23)
|
|
})
|
|
|
|
it('matches the display contract: roundForActiveCurrency(convertCost(x)) is what users see', async () => {
|
|
await setActive('JPY', 150)
|
|
// Dashboard displays via formatCost which uses getFractionDigits=0 for JPY.
|
|
// CSV exports must produce the same integer value, not a 2-decimal float.
|
|
expect(roundForActiveCurrency(convertCost(2.75))).toBe(413)
|
|
expect(roundForActiveCurrency(convertCost(2.745))).toBe(412)
|
|
})
|
|
})
|
|
|
|
describe('getFractionDigits', () => {
|
|
it('returns 0 for zero-fraction currencies', () => {
|
|
expect(getFractionDigits('JPY')).toBe(0)
|
|
expect(getFractionDigits('KRW')).toBe(0)
|
|
expect(getFractionDigits('CLP')).toBe(0)
|
|
})
|
|
|
|
it('returns 2 for typical currencies', () => {
|
|
expect(getFractionDigits('USD')).toBe(2)
|
|
expect(getFractionDigits('EUR')).toBe(2)
|
|
expect(getFractionDigits('GBP')).toBe(2)
|
|
expect(getFractionDigits('INR')).toBe(2)
|
|
})
|
|
})
|