mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
Every project path under a temp root (os.tmpdir(), /tmp, /var/folders and their /private spellings) that is not a checkout of a known repository folds into one row, in the report JSON, By Project, the terminal dashboard, the top bar picker and the Settings Projects pane. Its pattern @temp selects or hides all of them. The Projects pane leaves out rows that round to $0.00 unless a saved pattern names them, and a saved pattern naming any checkout of a repository row now names the whole row in the renderer too.
345 lines
14 KiB
TypeScript
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: '/work/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: '/work/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)
|
|
})
|
|
})
|