codeburn/tests/dashboard-progressive-startup.test.ts
Resham Joshi 2fc62f7876
0.9.25: correctness, energy, menu bar and installer fixes (#1511)
* 0.9.25: correctness + provider fixes, energy guard, logo, default DMG

CLI/engine fixes: local-model savings count reasoning; ZCode reasoning billed;
codex-credits version-boundary tiering; GLM alias repointed to z.ai discounted rate;
openclaw/pi/qwen timestamp mtime fallback; hermes delta timestamp;
sqlite/warp/hermes busy+permission reclassified as failed (no sealed empty);
codex-cache dev+ino; cursor-cache WAL; antigravity server TTL; quota timeout+
grokbot footer+claude near-expiry; serve RSS guard env+hysteresis; CSV export
subfolder; providers pane fixed window; Warp credit-based cost.
Desktop/menubar: unified flame logo; default DMG; docs signed+notarized.

* menubar: one running instance, self-relaunch on language change

The single-instance guard only retired strictly older copies, so two login
items starting in the same instant both survived, and a copy whose bundle
identity did not survive install was never seen at all. The decision is now
total (newest launch wins, ties break on pid) and peers are matched on bundle
id or executable name. --keep-both opts one launch out.

Language changes no longer have the desktop app quit and reopen the menu bar,
which made macOS attribute the new process to the desktop app and re-ask for
the data-access grant. The menu bar relaunches itself via the existing remote
command key; older menu bars that do not watch the key fall back to the old
path after 5s.

* desktop: export toast shows the saved path; resource buckets on app_close

The export toast showed the folder that was picked, not the dated subfolder
the files land in. The handler already parsed the CLI's saved line; it now
returns that path and the toast uses it.

app_close carries cpu/mem (and serveCpu/serveMem when serve reported) as bucket
labels, sampled on the existing flush interval. serve adds cpuSec and peak rssMb
to its answer frames. Unmeasured figures are omitted, never sent as zero.

* menubar guard never leaves zero instances; installer replaces in place; JSON export into folder

Guard: an unreadable launch date on either side could make two copies retire
each other. Unreadable peers now count only when their pid is lower, and a copy
that cannot read its own date retires lower pids only, so a disagreement keeps
at least one instance.

Installer: an existing bundle in /Applications or ~/Applications is replaced
where it lives instead of a second copy being added. The old bundle is moved
aside and restored on any failure; a cross-volume move falls back to a copy that
is re-verified with codesign. An unwritable location is never escalated: the
install goes to ~/Applications and the leftover is named.

Export: -o pointing at an existing folder writes the JSON inside it. Short
sessions omit the cpu buckets. setLanguage reopens the menu bar if its own
relaunch never comes back. AppRelaunch passes the bundle path as an argument.

* menubar guard orders by kernel start time; installer opens the copy that exists and recovers a killed install

Guard: launch dates could be unreadable and pid order can disagree with start
order after pid reuse, so two copies could still retire each other. Ordering now
uses the kernel-reported process start time, which both sides read identically.
A copy whose start time cannot be read is left out, so the worst case is two
menu bars, never none.

Installer: with the only copy in an unwritable /Applications, a plain run tried
to open a path that did not exist. It now opens the installed copy. A placement
killed mid-way is recovered on the next run, a concurrent install is refused,
and a failed restore names where the previous bundle is. Process matching uses
the exact executable name.

Export: a trailing separator names a folder. Telemetry cpu is measured from
telemetry start.

* installer: a recycled pid can no longer block installs; language change waits for the replacement process

An aside left by an earlier install counted as an active install whenever its
pid was live, so once that pid was reused every install was refused for good.
It now also has to be recent (ctime within 10 minutes), a pid we may not signal
is not ours, the refusal names the file, and an orphan that cannot be cleared is
reported instead of aborting the install.

setLanguage confirmed the relaunch with a matcher the dying process still
satisfied, so the reopen fallback never fired. It now waits for a pid that was
not running before.

* desktop: only the headline refreshes live; act, yield and the optimize scan move to slower tiers

An open window ran act report (a fresh CLI process each time), yield and an
optimize scan on every refresh. The headline poll now passes --no-optimize and
takes the snapshot path; act report refreshes on mount, on manual refresh and
every 10 minutes, yield every 5. The optimize scan runs at most once a day per
query scope, is stored in userData with its time, and is forced fresh by the
Optimize page or a manual refresh. Figures that are not live show their age,
and no stored scan means no savings clause rather than a zero.

On battery the live interval doubles; the chosen cadence stays the base.

* desktop: a stored optimize scan is only valid on the day it was computed; cadence changes no longer refetch

The scan cache is keyed on the argv, which says 'today' or '30days', not a date,
so yesterday's scan was served as today's. It is now reused only on the same
local day. Stored findings in Signals carry their age, a failed scan shows the
error instead of scanning forever, and the cold-boot label uses the time the app
received the payload.

usePolled fetched immediately whenever its interval changed, so every power
transition (and launching on battery) refetched every live hook and could spawn
an extra act report. Fetching and timer arming are now separate effects.
Optimize's yield is on the same 5 minute tier as Overview.

* desktop: re-ask for the optimize scan on the first render of a new local day

An app left open across midnight kept yesterday's stored savings under Today,
because the scan was only requested on mount, scope change or manual refresh.
The local day is now one of the hook's triggers; the previous day's figure is
dropped while the new scan runs.

* serve: a menubar poll is never answered from the status snapshot

Inside its settle window the status snapshot returns the pre-change payload on
purpose. The resident serve process then memoized that answer as clean, so after
a single write followed by quiet the headline stayed on the old figure until the
next write or the five minute memo cap. The snapshot is for one-shot runs; the
resident process already holds the parsed state, so it now skips it.

* tests stop writing the real serve pid file; superseded caches are swept; installer notes reach the desktop card

ServeConnection takes its pid file as a parameter, so the Swift tests no longer
share ~/.cache/codeburn/menubar-serve.pid with a running menu bar, and the
reaper only signals a record that is a serve --stdio command.

Superseded session-cache, codex-results and cursor-results files (100MB+ each)
are removed once untouched for 14 days, at most once a day. daily-cache and the
hermes ledger are never swept: they carry sealed days a version bump adopts.

The menu bar installer's leftover-copy advice is emitted as a marked line under
CODEBURN_PROGRESS and shown on the desktop card. The skipped-refresh NSLog is
debug only. The app's vitest workers turn off Node's built-in web storage.

* menubar: anonymous usage statistics, under the same consent as the desktop and the Windows tray

The Swift menu bar sent nothing, so every codeburn-menubar row was a Windows
tray. It now follows the desktop's telemetry.v1.json when that exists (read
only: same install id, same on/off, onboarded required) and otherwise keeps its
own state with the same region default (off in EU/EEA/UK/CH and when the region
is unknown). Settings gains one Privacy toggle, disabled when the desktop owns
the choice. Events use the tray's names; the daily usage snapshot is the CLI's
block passed through a sanitizer and is left to the desktop when it is the
consent source. Debug builds never send.

A resident CLI that gets disabled for the run now says so in the log.

* menubar statistics: private network session, abandoned batch on opt-out, a desktop No survives the desktop going away

The sender used URLSession.shared, so a server or CDN cookie and the default
User-Agent rode along and would have outlived an opt-out. It now owns an
ephemeral session with no cookies, no cache, a fixed agent string and no
redirects. A batch that is out when the toggle goes off is dropped rather than
re-queued under the fresh id. The desktop's answer is recorded locally, so a
missing or damaged state file never falls back to the region default, and the
standalone id is persisted when minted. The toggle stays usable as a local off
switch under desktop consent. Copy states what this app actually sends.

* vercel gateway spend is shown on its own row and kept out of totals by default

The gateway reports daily aggregates per model, with nothing to match against
the local tools that were pointed at it, so every headline counted that traffic
twice. Totals now leave the gateway slice out when all providers are shown; the
row keeps its full amount and is marked not in total, --provider vercel-gateway
is unchanged, and codeburn gateway-totals include folds it back in. The daily
cache still stores the slice, so the choice applies retroactively. Reports with
no gateway credential are byte-identical.

The gateway's request_count now feeds the call count instead of each daily row
counting as one call. Hermes: a mid-session price change lands on the day it is
observed and sealed days are never rewritten; stated in the code and changelog.

* gateway exclusion applies to every report through the parse, and the detail panels follow the headline

The first pass narrowed only the day totals, so By tool, top models, top
projects, activities, status json projects and any project-filtered table still
carried gateway spend next to a headline that did not, and By tool shares could
pass 100%. The rule now lives in parseAllSessions: with all providers in scope
and the opt-in off, aggregate-only sessions are dropped after the memo, and the
callers that derive cached days opt out so stored slices are unchanged.
day.projects is reduced like models. The gateway stays visible once, as a
labelled not-in-total row with no share, and standalone reports note the
excluded amount on stderr so machine output is unchanged.

* dashboard: the background history index follows the gateway exclusion too

The index keeps the whole corpus so it can fill the daily cache, but its
per-period projection handed that unfiltered list to the project, model and
activity panels, so they regained gateway spend once the index landed.

* ci: a hung lock suite fails in 90s and retries once with a warning; slow Windows tests get measured limits

The cache-lock step failed 6 of the last 60 runs with no output for five minutes
after the vitest banner; passing runs take 12-18s on every Node version. Each
attempt is now capped at 90s and retried once, with a warning whenever that
happens. On windows-latest a single CLI spawn takes 1.4-12s, so the four-spawn
status snapshot test and the c6 parser suite get limits taken from those runs,
each with its reason. Both test jobs print their ten slowest cases.

* desktop: Privacy & data uses the same rows as the rest of Settings

The pane mixed two static claims with two controls in its own layout. It now
uses the about-row pattern every other pane uses: title and hint on the left,
one control on the right. The claims became the subtitle. Rows: anonymous usage
statistics (toggle, link to what is sent), device sharing state with Manage,
local report snapshots with Clear, export with Open export.

* desktop: Privacy & data keeps one bounded column and rows that never squeeze

The card stretched with the window, stranding each control hundreds of pixels
from its text. This pane's heading, subtitle and card now share a 760px column
and one text measure. Rows stack on their own width, because the window can
never be narrow enough for the old viewport rule to fire; row buttons do not
wrap. The what-is-sent link sits on its own line, and the snapshot and export
descriptions fit their measure and say this machine.

* menubar tests clean up their scratch defaults and leave the real Keychain alone

Four test files never removed their UserDefaults suites and the rest removed the
domain but left the plist, so thousands of files piled up in
~/Library/Preferences. A shared helper forgets a suite and deletes its file, and
every test that makes a suite now uses it. cfprefsd still rewrites an empty plist
when the test process exits, so a run leaves about a hundred 42-byte files with
no test data in them. The real-Keychain round trip only runs with
CODEBURN_TEST_REAL_KEYCHAIN=1.

* desktop: the resident parser retires after 15 idle minutes and restarts on demand

The renderer stops polling while the window is hidden, but the serve child stayed
resident with its parsed cache, one to two GB on a large corpus. After 15 minutes
without a request it is stopped the way a settings restart stops it, without
spending the unexpected-death budget, and the next request starts a new resident
through the existing lazy path, never a one-shot. CODEBURN_SERVE_IDLE_MS tunes or
disables it.

* desktop: every settings pane keeps a bounded column, and dark buttons have an edge

Cards stretched with the window, so on a wide display controls sat up to 1300px
from their labels and intro lines ran past 140 characters. All panes now share
one left-aligned column (760px, 980px for projects, aliases and pricing, which
hold wide rows) and one text measure. Rows stack on their own width instead of a
viewport rule the 900px minimum window could never reach, and a long path in
Projects no longer overflows the pane.

In dark mode the secondary button matched or sat below its surface, and was
invisible on the plugin install dialog. It gets a face and a border from two new
tokens; light is unchanged. The onboarding consent label matches Settings.

* desktop: this device shows its fingerprint on its own line

The local name and the fingerprint are two adjacent inline spans, so they read as
one run of text.

* menubar: the resident parser retires after 15 idle minutes and restarts on demand

The menu bar kept its serve child, and the parsed cache with it, for the whole
run, even with the popover closed and nothing changing. After 15 minutes without
a request the child is retired without counting as a death, and the next request
starts a new resident through ensureStarted, never the one-shot fallback, whose
snapshot settle window would make the figure lag a whole tick. The window sits
above the longest gap between real requests, so a child stays warm while work is
happening. CodeBurnServeIdleSeconds tunes or disables it.

* serve: a poll re-checks only what the watcher saw change, and shard writes are coalesced

With a session being written, every poll re-listed about 2,400 directories and
re-fingerprinted about 6,000 transcripts, then rewrote the whole current-month
shard (49MB on a large corpus) and read it back on the next poll. The resident
process now reuses the previous listing and fingerprints for paths its root
watcher proves untouched, and falls back to the full sweep whenever the watcher
is missing, unhealthy, overflowed, armed after the sweep, does not cover the
path, the memo is older than 4.5 minutes, or the process was suspended. Shard
publication is held for up to 90s and flushed when stdin closes; a held write is
dropped rather than written over something another process published. One-shot
runs are unchanged. Active poll cost 0.71 to 0.33 CPU-s at a 30s cadence;
time-to-reflect unchanged at about 1.2s across append, new dir, new file, delete,
rename-over, in-flight, watcher overflow and unhealthy watcher.

Changelog: state the telemetry default as it is in this version.

* a retired parser gets to publish its held shards, and durable providers are never held

Both apps retired the serve child with SIGTERM before its stdin-close flush could
run, so every retire discarded the coalesced window. They now close stdin, give
the child a bounded grace to publish and exit, and signal it only if it is still
running; a settings restart does the same. Measured: flushed 0/5 before, 5/5
after, for both clients.

copilot's cache entries are the only record once Copilot prunes its own files, so
a held window that was dropped (retire, crash, kill -9) before the source was
pruned lost that spend for good. A window that a durable provider dirtied is
published on the poll that parsed it, as before coalescing existed. The RSS guard
publishes a held window before it clears the caches instead of discarding it.

* serve: release the lifetime parse after the fill, and make the collection real

The RSS guard dropped its memos and then called globalThis.gc, which exists only
under --expose-gc; no launcher passes it, so the collection had never run and the
freed pages never went back. On a cold start the resident idled at 2.2GB with
364MB of live heap: clearing every memo moved RSS by 0MB, a collection alone by
700MB.

After the background fill has sealed its summaries the process now publishes any
held shard window, drops the memos it can re-read from disk (the cold start had
loaded the cache with scope 'all', which then served every later poll), and
collects; it collects again a minute later for as long as a pass returns 64MB,
at most four times. Cold idle 2198MB to 700MB, warm 900MB to 781MB. Reports are
leaf-identical cold and warm, time-to-reflect unchanged, a released month scope
reloads in 0.3-0.5s.

* warp: the billed cost reaches reports and survives the cache

The provider derived a dollar cost from Warp's own billing record (provider cents,
charged usage, or credits), but warp was missing from the reported-cost
pass-through, so the cached call kept no cost and every report, cold or warm,
priced the conversation from its tokens. A fixture billed at $1.22 reported
$0.15. A call whose cost came from a billing record now keeps it; a call that
only has the token floor stores none and is still re-priced when prices change.

Warp entries re-parse once (provider parse version), and the daily cache moves to
v34 so days whose Warp source still exists re-derive; call counts do not move, so
nothing is double counted or dropped.

* openclaw and pi: a reported cost survives the cache like every other reported cost

Both providers read the cost their tool recorded for a message, and both were
missing from the reported-cost pass-through, so reports priced every call from
tokens instead. Fixtures reported $0.011 against $0.056 (openclaw) and $0.013
against $0.410 (pi). A call with a reported cost now keeps it; a call with none,
or an explicit zero, stores none and is still re-priced when prices change. omp
had been added to the list in #1150 and pi, the same parser, was never revisited.

The provider list is one named set, the unreachable providerCallToTurn is gone,
and both providers re-parse once; the v34 daily cache re-derives their days with
call counts unchanged.

* test: pin parse count per report and per resident poll
2026-09-21 00:16:21 +02:00

345 lines
14 KiB
TypeScript

import { PassThrough } from 'node:stream'
import React from 'react'
import { render } from 'ink'
import stripAnsi from 'strip-ansi'
import { afterEach, beforeEach, describe, expect, it, onTestFinished } from 'vitest'
import { appendFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
assembleDashboardFirstPaint,
buildDashboardHistoryIndex,
DASHBOARD_COLD_INDEX_PHASES,
dashboardIndexSupportsPeriod,
InteractiveDashboard,
selectDashboardHistoryIndex,
shouldAutoFallbackToWeek,
} from '../src/dashboard.js'
import { getDateRange, type Period } from '../src/cli-date.js'
import { clearSessionCache, filesParsedFromSourceCount, isCompleteSessionSnapshotAvailable, parseAllSessions, sessionMemoPublicationCount } from '../src/parser.js'
import { clearLoadCacheMemo, fingerprintFileCount, isColdCacheOnDisk } from '../src/session-cache.js'
import { buildDurablePeriod } from '../src/usage-aggregator.js'
import type { ProjectSummary } from '../src/types.js'
import { setHome } from './setup/home.js'
const DAY_MS = 24 * 60 * 60 * 1000
let tmpDir: string
const originalHome = process.env['HOME']
beforeEach(async () => {
clearSessionCache()
clearLoadCacheMemo()
tmpDir = await mkdtemp(join(tmpdir(), 'dashboard-progressive-'))
setHome(tmpDir)
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
})
afterEach(async () => {
clearSessionCache()
clearLoadCacheMemo()
delete process.env['CLAUDE_CONFIG_DIR']
delete process.env['CODEBURN_CACHE_DIR']
delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
if (originalHome == null) setHome(undefined)
else setHome(originalHome)
await rm(tmpDir, { recursive: true, force: true })
})
async function writeSession(name: string, ageDays: number, outputTokens = 50): Promise<void> {
const dir = join(tmpDir, 'projects', 'proj')
await mkdir(dir, { recursive: true })
const at = new Date(Date.now() - ageDays * DAY_MS)
const path = join(dir, `${name}.jsonl`)
await writeFile(path, `${JSON.stringify({
type: 'assistant',
sessionId: name,
timestamp: at.toISOString(),
cwd: '/tmp/proj',
message: {
id: `msg-${name}`,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [],
usage: { input_tokens: 100, output_tokens: outputTokens },
},
})}\n`)
await utimes(path, at, at)
}
async function appendSessionCall(name: string, outputTokens: number): Promise<void> {
const timestamp = new Date().toISOString()
await appendFile(join(tmpDir, 'projects', 'proj', `${name}.jsonl`), `${JSON.stringify({
type: 'assistant',
sessionId: name,
timestamp,
cwd: '/tmp/proj',
message: {
id: `msg-${name}-appended`,
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-5',
content: [],
usage: { input_tokens: 100, output_tokens: outputTokens },
},
})}\n`)
}
async function renderAutoPeriodDashboard(initialProjects: ProjectSummary[], initialDurable: Awaited<ReturnType<typeof assembleDashboardFirstPaint>>['result']['initialDurable']) {
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
stdin.isTTY = true
stdin.setRawMode = () => stdin
stdin.ref = () => stdin
stdin.unref = () => stdin
stdout.isTTY = true
stdout.columns = 120
stdout.rows = 50
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
let indexed!: () => void
const settled = new Promise<void>(resolve => { indexed = resolve })
const app = render(React.createElement(InteractiveDashboard, {
initialProjects,
initialPeriod: 'today',
initialProvider: 'all',
initialDurable,
refreshSeconds: 0,
windowColumns: 120,
initialHistoryIndexing: true,
initialCacheWasCold: true,
autoFallbackFromEmptyToday: true,
onHistoryIndexed: indexed,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
const settledFrame = async (): Promise<string> => {
await settled
await app.waitUntilRenderFlush()
return frames.filter(value => value.trim()).at(-1) ?? ''
}
return { app, frames, settledFrame }
}
describe('interactive dashboard progressive startup', () => {
it('selects and loads 7 Days in the rendered TUI when Today has exactly zero usage', async () => {
await writeSession('older', 3)
const paint = await assembleDashboardFirstPaint(
'today', 'all', undefined, undefined, null, null, false,
)
expect(paint.result.filteredProjects).toEqual([])
const { app, frames, settledFrame } = await renderAutoPeriodDashboard(
paint.result.filteredProjects,
paint.result.initialDurable,
)
await app.waitUntilRenderFlush()
expect(frames.filter(value => value.trim()).at(-1)).toContain('[ Today ]')
const frame = await settledFrame()
expect(frame).toContain('[ 7 Days ]')
expect(frame).toContain('proj')
})
it('preserves Today in the rendered TUI when Today has any usage', async () => {
await writeSession('today', 0)
await writeSession('older', 3)
const paint = await assembleDashboardFirstPaint(
'today', 'all', undefined, undefined, null, null, false,
)
expect(paint.result.filteredProjects).toHaveLength(1)
const { app, frames, settledFrame } = await renderAutoPeriodDashboard(
paint.result.filteredProjects,
paint.result.initialDurable,
)
await app.waitUntilRenderFlush()
expect(frames.filter(value => value.trim()).at(-1)).toContain('[ Today ]')
const frame = await settledFrame()
expect(frame).toContain('[ Today ]')
expect(frame).not.toContain('[ 7 Days ]')
})
it('falls back from an all-zero Today on cold and warm indexes, but keeps real usage on Today', () => {
const cache = { version: 29, savingsConfigHash: '', lastComputedDate: null, days: [], complete: true } as const
const coldWeek = { provider: 'all', normalizedProjects: [], cache, planUsages: [], readyThrough: 'week' as const }
const warmLifetime = { ...coldWeek, readyThrough: 'lifetime' as const }
const zeroSessionProject = {
project: 'zero', projectPath: '/tmp/zero', sessions: [{
sessionId: 'zero', project: 'zero', firstTimestamp: '', lastTimestamp: '', totalCostUSD: 0,
totalSavingsUSD: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0,
totalCacheWriteTokens: 0, apiCalls: 0, turns: [], modelBreakdown: {}, toolBreakdown: {},
mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {},
}], totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0,
} as ProjectSummary
const realUsage = { ...zeroSessionProject, totalApiCalls: 1 }
expect(shouldAutoFallbackToWeek(true, false, 'today', coldWeek, [])).toBe(true)
expect(shouldAutoFallbackToWeek(true, false, 'today', warmLifetime, [zeroSessionProject])).toBe(true)
expect(shouldAutoFallbackToWeek(true, false, 'today', coldWeek, [realUsage])).toBe(false)
expect(shouldAutoFallbackToWeek(true, false, 'today', warmLifetime, [realUsage])).toBe(false)
})
it('does not treat an empty cache as a usable complete snapshot', async () => {
expect(await isCompleteSessionSnapshotAvailable(getDateRange('today').range, 'all')).toBe(false)
await parseAllSessions(getDateRange('today').range, 'all')
clearSessionCache()
clearLoadCacheMemo()
expect(await isCompleteSessionSnapshotAvailable(getDateRange('today').range, 'all')).toBe(false)
await writeSession('arrived-after-empty-cache', 0)
const paint = await assembleDashboardFirstPaint(
'today', 'all', undefined, undefined, null, null, false,
)
expect(paint.result.filteredProjects.flatMap(project => project.sessions).map(session => session.sessionId))
.toEqual(['arrived-after-empty-cache'])
clearSessionCache()
clearLoadCacheMemo()
expect(await isCompleteSessionSnapshotAvailable(getDateRange('today').range, 'codex')).toBe(false)
})
it('discovers current source files when the normalized cache is cold', async () => {
await writeSession('today', 0)
const paint = await assembleDashboardFirstPaint(
'today', 'all', undefined, undefined, null, null, false,
)
expect(paint.result.filteredProjects.flatMap(project => project.sessions).map(session => session.sessionId))
.toEqual(['today'])
expect(filesParsedFromSourceCount()).toBeGreaterThan(0)
})
it('paints Today first even when the normalized session cache is already complete', async () => {
await writeSession('today', 0)
await writeSession('old', 90)
const configDir = join(tmpDir, '.config', 'codeburn')
await mkdir(configDir, { recursive: true })
await writeFile(join(configDir, 'config.json'), JSON.stringify({
plans: { claude: { id: 'claude-max', monthlyUsd: 200, resetDay: 1 } },
}))
await parseAllSessions(getDateRange('lifetime').range, 'all')
await buildDurablePeriod(getDateRange('lifetime'), { provider: 'all' })
expect(await isColdCacheOnDisk()).toBe(false)
clearSessionCache()
clearLoadCacheMemo()
const parsedBefore = filesParsedFromSourceCount()
const fingerprintsBefore = fingerprintFileCount()
const paint = await assembleDashboardFirstPaint(
'today', 'all', undefined, undefined, null, null, false,
)
expect(paint.result.period).toBe('today')
expect(paint.result.filteredProjects.flatMap(project => project.sessions).map(session => session.sessionId)).toEqual(['today'])
expect(paint.result.planUsages).toEqual([])
expect(paint.deferredFiles).toBe(0)
expect(filesParsedFromSourceCount() - parsedBefore).toBe(0)
expect(fingerprintFileCount() - fingerprintsBefore).toBe(0)
})
it('projects every period from one normalized lifetime index without returning to source files', async () => {
const periods: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime']
await Promise.all([
writeSession('today', 0),
writeSession('week', 5),
writeSession('month', 20),
writeSession('older', 45),
writeSession('six-months', 120),
writeSession('lifetime', 500),
])
const baseline = new Map<Period, Awaited<ReturnType<typeof buildDurablePeriod>>>()
for (const period of periods) {
baseline.set(period, await buildDurablePeriod(getDateRange(period), { provider: 'all' }))
}
const index = await buildDashboardHistoryIndex('all', undefined, undefined)
const parsedAfterIndex = filesParsedFromSourceCount()
await rm(join(tmpDir, 'projects'), { recursive: true, force: true })
for (const period of periods) {
const selected = selectDashboardHistoryIndex(index, period)
const expected = baseline.get(period)!
expect(selected.durable).toEqual({
cost: expected.data.cost,
savingsUSD: expected.data.savingsUSD,
calls: expected.data.calls,
sessions: expected.data.sessions,
sessionCountBasis: expected.data.sessionCountBasis,
inputTokens: expected.data.inputTokens,
outputTokens: expected.data.outputTokens,
cacheReadTokens: expected.data.cacheReadTokens,
cacheWriteTokens: expected.data.cacheWriteTokens,
carriedCostUSD: expected.carriedCostUSD,
excludedGateway: expected.excludedGateway,
})
expect(selected.projects.flatMap(project => project.sessions).map(session => session.sessionId).sort())
.toEqual(expected.liveProjects.flatMap(project => project.sessions).map(session => session.sessionId).sort())
}
expect(filesParsedFromSourceCount()).toBe(parsedAfterIndex)
})
it('does not let the fast cached snapshot suppress source reconciliation', async () => {
await writeSession('today', 0)
await writeSession('old', 90)
await parseAllSessions(getDateRange('lifetime').range, 'all')
clearSessionCache()
clearLoadCacheMemo()
// The source changes before launch, with no resident watcher available to
// invalidate an in-process memo. Cached first paint may show the old value;
// its immediately-following source reconciliation must replace it.
await appendSessionCall('today', 450)
const beforeSnapshot = fingerprintFileCount()
const memoPublicationsBefore = sessionMemoPublicationCount()
const snapshot = await buildDashboardHistoryIndex('all', undefined, undefined, {
readyThrough: 'lifetime',
preferCompleteSnapshot: true,
})
const afterSnapshot = fingerprintFileCount()
expect(afterSnapshot).toBe(beforeSnapshot)
expect(sessionMemoPublicationCount()).toBe(memoPublicationsBefore)
expect(snapshot.normalizedProjects.flatMap(project => project.sessions)
.find(session => session.sessionId === 'today')?.totalOutputTokens).toBe(50)
const refreshed = await buildDashboardHistoryIndex('all', undefined, undefined)
expect(fingerprintFileCount()).toBeGreaterThan(afterSnapshot)
expect(refreshed.normalizedProjects.flatMap(project => project.sessions)
.find(session => session.sessionId === 'today')?.totalOutputTokens).toBe(500)
})
it('widens a cold index in readiness order without parsing a source twice', async () => {
const periods: Period[] = ['today', ...DASHBOARD_COLD_INDEX_PHASES]
await Promise.all([
writeSession('today', 0),
writeSession('week', 5),
writeSession('month', 20),
writeSession('older', 45),
writeSession('six-months', 120),
writeSession('lifetime', 500),
])
const parsedBefore = filesParsedFromSourceCount()
let previousParsed = parsedBefore
for (const [position, readyThrough] of DASHBOARD_COLD_INDEX_PHASES.entries()) {
const index = await buildDashboardHistoryIndex('all', undefined, undefined, {
readyThrough,
progressiveSource: true,
})
for (const [periodPosition, period] of periods.entries()) {
expect(dashboardIndexSupportsPeriod(index, period)).toBe(periodPosition <= position + 1)
}
const parsed = filesParsedFromSourceCount()
expect(parsed).toBeGreaterThanOrEqual(previousParsed)
previousParsed = parsed
}
expect(filesParsedFromSourceCount() - parsedBefore).toBe(6)
})
})