codeburn/tests/dashboard.test.ts
iamtoruk 6b427d72ce fix(models): honest unpriced guidance, shared ID sanitizing, readable narrow warning
The unpriced-models pointer told every user to run `codeburn model-alias`. A
subscription or flat-rate model is correctly $0, and mapping it onto another
model's per-token rate invents spend that was never billed (#968), so the hint
now states the condition instead of the instruction.

`sanitizeModelForDisplay` guarded only the `--unpriced` path, leaving every
other command and format rendering provider-supplied IDs unfiltered. It moves
to the `formatModel` closures in models-report and audit-report, the two sites
every renderer routes through, covering the raw-ID fallback as well. The
`--unpriced` override stays: it bypasses that path deliberately, because
`model-alias` keys on the raw ID, not the friendly name. README says so.

Below 45 columns of panel the dashboard warning dropped its marker and count
and printed a bare command, so a narrow terminal gave no signal that anything
was wrong. `! N: codeburn models --unpriced` is 31 characters and fits.
2026-08-18 09:58:25 -07:00

840 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { homedir } from 'os'
import { readFileSync } from 'node:fs'
import { PassThrough } from 'stream'
import React from 'react'
import { render } from 'ink'
import stripAnsi from 'strip-ansi'
import { describe, it, expect, onTestFinished, vi } from 'vitest'
import { DAILY_ACTIVITY_PAGE_SIZE, INTERACTIVE_RENDER_OPTIONS, dailyActivityFooter, getDailyActivityPageSize, getDailyActivityRows, getDashboardMaxWidth, getDashboardScanRange, getLayout, getRefreshIntervalMs, InteractiveDashboard, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
import { getDateRange } from '../src/cli-date.js'
import { formatCost } from '../src/format.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
const EMPTY_CATEGORY_BREAKDOWN = {
coding: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
} satisfies SessionSummary['categoryBreakdown']
function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z'): SessionSummary {
return {
sessionId: id,
project: 'test-project',
firstTimestamp: timestamp,
lastTimestamp: timestamp,
totalCostUSD: cost,
totalSavingsUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN },
skillBreakdown: {},
subagentBreakdown: {},
}
}
function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary {
return {
project: name,
projectPath: name,
sessions,
totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0),
totalApiCalls: sessions.reduce((s, x) => s + x.apiCalls, 0),
}
}
function makeTurn(timestamp: string, costs: number[]): SessionSummary['turns'][number] {
return {
userMessage: 'fixture turn',
sessionId: 'fixture-session',
timestamp,
category: 'coding',
retries: 0,
hasEdits: false,
assistantCalls: costs.map((costUSD, index) => ({
provider: 'codex',
model: 'test-model',
usage: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 },
costUSD,
tools: [],
mcpTools: [],
skills: [],
subagentTypes: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp,
bashCommands: [],
deduplicationKey: `fixture-${timestamp}-${index}`,
})),
}
}
// Logic replicated from TopSessions component
function getTopSessions(projects: ProjectSummary[], n = 5) {
const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectPath: p.projectPath })))
return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n)
}
// Logic replicated from ProjectBreakdown component
function avgCostLabel(project: ProjectSummary): string {
return project.sessions.length > 0
? formatCost(project.totalCostUSD / project.sessions.length)
: '-'
}
describe('TopSessions - top-5 selection', () => {
it('returns all sessions when fewer than 5 exist', () => {
const project = makeProject('proj', [
makeSession('s1', 1.0),
makeSession('s2', 2.0),
])
const top = getTopSessions([project])
expect(top).toHaveLength(2)
expect(top[0].totalCostUSD).toBe(2.0)
expect(top[1].totalCostUSD).toBe(1.0)
})
it('returns exactly 5 when more than 5 sessions exist', () => {
const sessions = [0.1, 0.5, 3.0, 1.0, 0.8, 2.0].map((cost, i) =>
makeSession(`s${i}`, cost)
)
const project = makeProject('proj', sessions)
const top = getTopSessions([project])
expect(top).toHaveLength(5)
expect(top[0].totalCostUSD).toBe(3.0)
expect(top[4].totalCostUSD).toBe(0.5)
})
it('is stable on tied costs - preserves input order for equal values', () => {
const sessions = [
makeSession('s1', 1.0),
makeSession('s2', 1.0),
makeSession('s3', 1.0),
]
const project = makeProject('proj', sessions)
const top = getTopSessions([project])
expect(top.map(s => s.sessionId)).toEqual(['s1', 's2', 's3'])
})
})
describe('shortProject - path shortening', () => {
const home = homedir()
it('preserves directory names containing dashes', () => {
expect(shortProject(`${home}/work/my-project`)).toBe('work/my-project')
})
it('preserves directory names containing dots', () => {
expect(shortProject(`${home}/work/my.app.io`)).toBe('work/my.app.io')
})
it('returns "home" for the home dir itself', () => {
expect(shortProject(home)).toBe('home')
})
it('does not strip a sibling whose name shares the home prefix', () => {
const sibling = `${home}-backup/proj`
expect(shortProject(sibling).endsWith('proj')).toBe(true)
expect(shortProject(sibling)).not.toMatch(/^-/)
})
it('keeps only the last 3 segments for deeply nested paths', () => {
expect(shortProject(`${home}/a/b/c/d/e/f`)).toBe('d/e/f')
})
it('handles paths outside the home dir', () => {
expect(shortProject('/opt/myproject')).toBe('opt/myproject')
})
it('elides the parent folder and date year before the project title', () => {
const path = `${home}/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex`
expect(shortProject(path, 51)).toBe('Codex/2026-07-30/global-agents-md-config-toml-codex')
expect(shortProject(path, 47)).toBe('…/2026-07-30/global-agents-md-config-toml-codex')
expect(shortProject(path, 44)).toBe('…/…-07-30/global-agents-md-config-toml-codex')
expect(shortProject(path, 34)).toBe('…/…-07-30/global-agents-md-config…')
})
})
describe('avg/s in ProjectBreakdown', () => {
it('returns dash for a project with no sessions', () => {
const project = makeProject('proj', [])
expect(avgCostLabel(project)).toBe('-')
})
it('returns formatted average cost across sessions', () => {
const sessions = [makeSession('s1', 2.0), makeSession('s2', 4.0)]
const project = makeProject('proj', sessions)
expect(avgCostLabel(project)).toBe(formatCost(3.0))
})
})
describe('Daily Activity history', () => {
it('uses one concrete six-month scan for standard dashboard periods', () => {
const scanRange = getDashboardScanRange('week', null, null)
const allRange = getDateRange('all').range
expect(scanRange.start.getTime()).toBe(allRange.start.getTime())
expect(scanRange.end.getTime()).toBe(allRange.end.getTime())
})
it('keeps non-interactive output scoped to the selected period', () => {
const scanRange = getDashboardScanRange('week', null, null, false)
const weekRange = getDateRange('week').range
expect(scanRange.start.getTime()).toBe(weekRange.start.getTime())
expect(scanRange.end.getTime()).toBe(weekRange.end.getTime())
})
it('derives the selected period from the bounded history scan', () => {
const recent = new Date().toISOString()
const old = new Date()
old.setMonth(old.getMonth() - 2)
const session = makeSession('s1', 0)
session.turns = [makeTurn(old.toISOString(), [1]), makeTurn(recent, [2])]
const selected = selectDashboardPeriodProjects([makeProject('proj', [session])], 'week', true)
expect(getDailyActivityRows(selected)).toEqual([
{ day: recent.slice(0, 10), cost: 2, calls: 1 },
])
})
it('aggregates every active day in chronological order', () => {
const session = makeSession('s1', 0)
session.turns = [
makeTurn('2025-01-02T12:00:00Z', [1.25, 0.75]),
makeTurn('2024-12-31T12:00:00Z', [3]),
]
expect(getDailyActivityRows([makeProject('proj', [session])])).toEqual([
{ day: '2024-12-31', cost: 3, calls: 1 },
{ day: '2025-01-02', cost: 2, calls: 2 },
])
})
it('pages one viewport and keeps the final page full', () => {
expect(pageHistoryCursor(0, 1, 35, 69)).toBe(34)
expect(pageHistoryCursor(34, -1, 35, 69)).toBe(0)
expect(pageHistoryCursor(0, -1, 35, 69)).toBe(0)
})
it('scrolls one row without moving past either end', () => {
expect(scrollHistoryCursor(0, 1, 14, 21)).toBe(1)
expect(scrollHistoryCursor(1, -1, 14, 21)).toBe(0)
expect(scrollHistoryCursor(0, -1, 14, 21)).toBe(0)
expect(scrollHistoryCursor(7, 1, 14, 21)).toBe(7)
})
})
describe('showEmptyState', () => {
it('keeps the clean empty state for a truly-new user in scrollable mode', () => {
expect(showEmptyState(0, true, 0, false)).toBe(true)
})
it('renders the dashboard while full history is still loading', () => {
expect(showEmptyState(0, true, 0, true)).toBe(false)
})
it('renders the dashboard when the period is empty but history exists', () => {
expect(showEmptyState(0, true, 3, false)).toBe(false)
})
it('non-scrollable mode (custom range, day view) keeps the original behavior', () => {
expect(showEmptyState(0, false, 0, false)).toBe(true)
expect(showEmptyState(2, false, 0, false)).toBe(false)
})
})
// Issue #767 item 3: the Daily Activity panel's "of N" is a count of days
// found by the bounded live scan, not the same population the Overview
// headline (durable cache) counts for the period. The two numbers are each
// correct for what they measure, but nothing on screen said so - label the
// denominator instead of changing it.
describe('dailyActivityFooter', () => {
it('labels the count as the scanned-days population, not a bare total', () => {
expect(dailyActivityFooter(0, 14, 37)).toBe('Showing 114 of 37 days scanned · newest first')
})
it('clamps the visible end to the row count', () => {
expect(dailyActivityFooter(30, 14, 37)).toBe('Showing 3137 of 37 days scanned · newest first')
})
})
describe('getLayout - dashboard width breakpoints', () => {
it('uses a single column at 89 columns or below', () => {
expect(getLayout(89)).toMatchObject({ dashWidth: 89, columnCount: 1, panelWidth: 89 })
})
it('switches to two columns at 90 columns', () => {
expect(getLayout(90)).toMatchObject({ dashWidth: 90, columnCount: 2, panelWidth: 45 })
})
it('keeps two columns through 134 columns', () => {
expect(getLayout(134)).toMatchObject({ dashWidth: 134, columnCount: 2, panelWidth: 67 })
})
it('switches to three columns at 135 columns', () => {
expect(getLayout(135)).toMatchObject({ dashWidth: 135, columnCount: 3, panelWidth: 45 })
})
it('continues growing three equal panels by one for every three columns', () => {
expect(getLayout(160)).toMatchObject({ dashWidth: 160, columnCount: 3, panelWidth: 53 })
expect(getLayout(161)).toMatchObject({ dashWidth: 161, columnCount: 3, panelWidth: 53 })
expect(getLayout(162)).toMatchObject({ dashWidth: 162, columnCount: 3, panelWidth: 54 })
expect(getLayout(165)).toMatchObject({ dashWidth: 165, columnCount: 3, panelWidth: 55 })
})
it('stops at the lesser of 256 columns or the source-data width', () => {
expect(getLayout(300)).toMatchObject({ dashWidth: 256, columnCount: 3, panelWidth: 85 })
expect(getLayout(300, 213)).toMatchObject({ dashWidth: 213, columnCount: 3, panelWidth: 71 })
})
it('derives the wide-layout ceiling from renderable source labels', () => {
const short = makeProject('short', [makeSession('short', 1)])
const long = makeProject('x'.repeat(200), [makeSession('long', 1)])
expect(getDashboardMaxWidth([long])).toBe(256)
expect(getDashboardMaxWidth([short])).toBeLessThan(256)
})
})
describe('Daily Activity viewport', () => {
it('shows ten dates at a time', () => {
expect(DAILY_ACTIVITY_PAGE_SIZE).toBe(10)
})
it.each([
{ columns: 1 as const, projectRows: 14, activityRows: 17, expected: 10 },
{ columns: 2 as const, projectRows: 8, activityRows: 17, expected: 10 },
{ columns: 2 as const, projectRows: 14, activityRows: 17, expected: 14 },
{ columns: 3 as const, projectRows: 8, activityRows: 7, expected: 10 },
{ columns: 3 as const, projectRows: 14, activityRows: 17, expected: 17 },
])('uses $expected rows for a $columns-column row with $projectRows project and $activityRows activity rows', ({ columns, projectRows, activityRows, expected }) => {
expect(getDailyActivityPageSize(columns, projectRows, activityRows)).toBe(expected)
})
it('keeps day mode to one date', () => {
expect(getDailyActivityPageSize(3, 14, 17, true)).toBe(1)
})
it('matches fourteen visible project rows in the two-column layout', async () => {
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 = 100
stdout.rows = 80
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
const historySession = makeSession('history', 20)
historySession.turns = Array.from({ length: 20 }, (_, index) =>
makeTurn(`2026-07-${String(index + 1).padStart(2, '0')}T10:00:00Z`, [1]))
const projects = [
makeProject('project-01', [historySession]),
...Array.from({ length: 13 }, (_, index) =>
makeProject(`project-${String(index + 2).padStart(2, '0')}`, [makeSession(`s-${index}`, 1)])),
]
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: projects,
initialPeriod: 'all',
initialProvider: 'all',
refreshSeconds: 0,
windowColumns: 100,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
await app.waitUntilRenderFlush()
let frame = frames.filter(value => value.trim()).at(-1) ?? ''
expect(frame.match(/2026-07-\d{2}/g)).toHaveLength(14)
expect(frame).toContain('2026-07-20')
stdin.write(' ')
await app.waitUntilRenderFlush()
frame = frames.filter(value => value.trim()).at(-1) ?? ''
expect(frame.match(/2026-07-\d{2}/g)).toHaveLength(14)
expect(frame).toContain('2026-07-01')
expect(frame).not.toContain('2026-07-20')
})
})
describe('getRefreshIntervalMs', () => {
it('allows disabled refresh and clamps enabled refreshes to one minute', () => {
expect(getRefreshIntervalMs(0)).toBe(0)
expect(getRefreshIntervalMs(30)).toBe(60_000)
expect(getRefreshIntervalMs(60)).toBe(60_000)
expect(getRefreshIntervalMs(300)).toBe(300_000)
})
})
describe('interactive terminal rendering', () => {
it('isolates resize reflow from stale primary-screen frames', () => {
expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true })
})
it.each([
{ columns: 42, expected: '! 10: codeburn models --unpriced' },
{ columns: 43, expected: '! 10: codeburn models --unpriced' },
{ columns: 44, expected: '! 10: codeburn models --unpriced' },
{ columns: 80, expected: '! 10 unpriced: codeburn models --unpriced' },
])('shows an actionable unpriced-model command in a real $columns-column Ink frame', async ({ columns, expected }) => {
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 = columns
stdout.rows = 100
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
const session = makeSession('unpriced-session', 0)
for (let index = 0; index < 10; index++) {
const model = `vendor-${index}/unknown-model-${index}-969`
session.modelBreakdown[model] = {
calls: 1,
costUSD: 0,
savingsUSD: 0,
tokens: {
inputTokens: 1_000,
outputTokens: 100,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
}
}
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: [makeProject('unpriced-project', [session])],
initialPeriod: 'today',
initialProvider: 'all',
refreshSeconds: 0,
windowColumns: columns,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
await app.waitUntilRenderFlush()
const frame = frames.filter(value => value.trim()).at(-1) ?? ''
expect(frame).toContain(expected)
})
it('labels claude.ai connector remediation as a manual action', async () => {
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))))
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Google_Calendar__t${i}`)
const sessions = ['connector-a', 'connector-b'].map((id, index) => {
const session = makeSession(id, 91.337 + index)
session.mcpInventory = inventory
return session
})
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: [makeProject('connector-manual-action', sessions)],
initialPeriod: 'today',
initialProvider: 'all',
refreshSeconds: 0,
windowColumns: 120,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
await app.waitUntilRenderFlush()
stdin.write('o')
let frame = ''
for (let i = 0; i < 100 && !frame.includes('Manual action'); i++) {
await new Promise(resolve => setTimeout(resolve, 10))
frame = frames.filter(value => value.trim()).at(-1) ?? ''
}
expect(frame).toContain('Manual action')
expect(frame).toContain('claude.ai Google Calendar')
expect(frame).not.toContain('Ask Claude in the current session')
})
it('leaves resize frame synchronization entirely to Ink', () => {
const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8')
expect(source).not.toContain('process.stdout.write(BSU)')
expect(source).not.toContain("process.stdout.write('\\u001B[2J\\u001B[H')")
expect(source).not.toContain('shouldResetScreenOnResize')
})
it.each([
{ label: 'today', period: 'today', expected: true },
{ label: 'week', period: 'week', expected: true },
{ label: 'a concrete day within a heavy period', period: 'all', initialDay: '2026-07-30', expected: true },
{ label: '30days', period: '30days', expected: false },
{ label: 'month', period: 'month', expected: false },
{ label: 'all', period: 'all', expected: false },
{ label: 'lifetime', period: 'lifetime', expected: false },
] as const)(
'schedules periodic dashboard refresh for $label: $expected',
async ({ period, initialDay, expected }) => {
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 = 160
stdout.rows = 50
const setIntervalSpy = vi.spyOn(global, 'setInterval')
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: [makeProject('proj', [makeSession('s1', 1)])],
initialPeriod: period,
initialProvider: 'all',
refreshSeconds: 60,
windowColumns: 160,
initialDay,
}), { stdin, stdout, interactive: true, patchConsole: false })
onTestFinished(() => {
app.unmount()
setIntervalSpy.mockRestore()
})
await app.waitUntilRenderFlush()
expect(setIntervalSpy.mock.calls.some(call => call[1] === 60_000)).toBe(expected)
},
)
it('accepts the next width before Ink paints each breakpoint transition', async () => {
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 = 135
stdout.rows = 50
const chunks: string[] = []
stdout.on('data', chunk => chunks.push(stripAnsi(String(chunk))))
const props = {
initialProjects: [makeProject('proj', [makeSession('s1', 1)])],
initialPeriod: 'today' as const,
initialProvider: 'all',
refreshSeconds: 0,
}
const app = render(React.createElement(InteractiveDashboard, { ...props, windowColumns: 135 }), {
stdin, stdout, interactive: true, patchConsole: false,
})
onTestFinished(() => app.unmount())
await new Promise(resolve => setTimeout(resolve, 20))
chunks.length = 0
app.rerender(React.createElement(InteractiveDashboard, { ...props, windowColumns: 134 }))
await app.waitUntilRenderFlush()
let panelTitleLine = (chunks.filter(chunk => chunk.trim()).at(-1) ?? '').split('\n').find(line => line.includes('Daily Activity')) ?? ''
expect(panelTitleLine).toContain('By Project')
expect(panelTitleLine).not.toContain('By Activity')
chunks.length = 0
app.rerender(React.createElement(InteractiveDashboard, { ...props, windowColumns: 89 }))
await app.waitUntilRenderFlush()
panelTitleLine = (chunks.filter(chunk => chunk.trim()).at(-1) ?? '').split('\n').find(line => line.includes('Daily Activity')) ?? ''
expect(panelTitleLine).not.toContain('By Project')
})
it.each([
{ columns: 80, rows: 12 },
{ columns: 100, rows: 18 },
{ columns: 160, rows: 24 },
])('pins and scrolls the full $columns-column dashboard without losing position', async ({ columns, rows }) => {
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 = columns
stdout.rows = rows
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
const props = {
initialProjects: [makeProject('proj', [makeSession('s1', 1)])],
initialPeriod: 'today' as const,
initialProvider: 'all',
refreshSeconds: 0,
windowColumns: columns,
}
const app = render(React.createElement(InteractiveDashboard, props), {
stdin, stdout, debug: true, interactive: true, patchConsole: false,
})
onTestFinished(() => app.unmount())
await app.waitUntilRenderFlush()
let frame = frames.filter(chunk => chunk.trim()).at(-1) ?? ''
expect(frame.split('\n')).toHaveLength(rows - 1)
expect(frame).toContain('[ Today ]')
stdin.write('\u001B[6~')
await app.waitUntilRenderFlush()
frame = frames.filter(chunk => chunk.trim()).at(-1) ?? ''
expect(frame).not.toContain('[ Today ]')
app.rerender(React.createElement(InteractiveDashboard, {
...props,
windowColumns: columns + 1,
}))
await app.waitUntilRenderFlush()
frame = frames.filter(chunk => chunk.trim()).at(-1) ?? ''
expect(frame).not.toContain('[ Today ]')
})
})
describe('InteractiveDashboard refresh', () => {
it('keeps ten metric columns compact and visible before shortening project titles', async () => {
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 = 135
stdout.rows = 100
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
const session = makeSession('s1', 19.43)
session.apiCalls = 2303
session.categoryBreakdown.coding = { turns: 12, costUSD: 1, savingsUSD: 0, retries: 0, editTurns: 10, oneShotTurns: 5 }
session.skillBreakdown.ponytail = { turns: 3, costUSD: 0.25, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 }
session.modelBreakdown['gpt-5.6-sol'] = {
calls: 2303,
costUSD: 257.44,
savingsUSD: 0,
estimatedCostUSD: 257.44,
activeDurationMs: 10_000,
activeGeneratedTokens: 539,
tokens: {
inputTokens: 1,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 99,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
}
session.modelBreakdown['gpt-5.6-terra'] = {
calls: 22,
costUSD: 0.63,
savingsUSD: 0,
tokens: {
inputTokens: 1,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
}
const project = makeProject('long-project', [session])
project.projectPath = '/Users/jared/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex'
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: [project],
initialPeriod: 'today',
initialProvider: 'all',
refreshSeconds: 0,
windowColumns: 135,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
let frame = ''
for (let i = 0; i < 100 && (!frame.includes('10.4K') || !frame.includes('By Model')); i++) {
await new Promise(resolve => setTimeout(resolve, 10))
frame = frames.filter(value => value.trim()).at(-1) ?? ''
}
for (const metric of ['cost', 'avg/s', 'session', 'overhead', 'cache', 'calls', '1-shot', 'Tok/s', 'turns', 'uses']) {
expect(frame, `missing ${metric}`).toContain(metric)
}
for (const value of ['$19.43', '10.4K', '~$257.44', '99.0%', '2303', '53.9', '$1.00', '12', '50%', '$0.25']) {
expect(frame, `missing ${value}`).toContain(value)
}
const modelHeader = frame.split('\n').find(line => line.includes('cache') && line.includes('1-shot')) ?? ''
const projectHeader = frame.split('\n').find(line => line.includes('avg/s')) ?? ''
const projectCostIndex = projectHeader.lastIndexOf('cost', projectHeader.indexOf('avg/s'))
expect(modelHeader.indexOf('Tok/s') + 'Tok/s'.length - modelHeader.indexOf('cost')).toBeLessThanOrEqual(33)
expect(projectHeader.indexOf('overhead') + 'overhead'.length - projectCostIndex).toBeLessThanOrEqual(30)
expect(frame).toContain('…/')
})
it('keeps project metric headings readable before long project paths', async () => {
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 = 80
stdout.rows = 100
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
const project = makeProject('long-project', [makeSession('s1', 19.43)])
project.projectPath = '/Users/jared/Documents/Codex/2026-07-30/global-agents-md-config-toml-codex'
project.sessions[0]!.modelBreakdown['gpt-5.6-sol'] = {
calls: 2303,
costUSD: 257.44,
savingsUSD: 0,
estimatedCostUSD: 257.44,
tokens: {
inputTokens: 1,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 99,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
}
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: [project],
initialPeriod: 'today',
initialProvider: 'all',
refreshSeconds: 0,
windowColumns: 80,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => app.unmount())
let frame = ''
for (let i = 0; i < 100 && !frame.includes('10.4K'); i++) {
await new Promise(resolve => setTimeout(resolve, 10))
frame = frames.filter(value => value.trim()).at(-1) ?? ''
}
expect(frame).toContain('10.4K')
expect(frame).toContain('~$257.44')
const projectHeader = frame.split('\n').find(line => line.includes('avg/s')) ?? ''
expect(projectHeader).toMatch(/cost\s+avg\/s\s+session\s+overhead/)
expect(projectHeader).not.toContain('sessover')
})
it('keeps Optimize mounted without a loading frame when auto-refresh fires', async () => {
// The Optimize scan (`o`) does real fs I/O (readdir/stat) that only
// resolves on a real event-loop turn. Leave setImmediate/nextTick/Date
// real (Date stays real so the wait loop below can use a genuine
// wall-clock deadline) and fake only what the 60s auto-refresh
// interval needs.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
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 = 160
stdout.rows = 50
const frames: string[] = []
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
const session = makeSession('s1', 1)
session.turns = Array.from({ length: 11 }, (_, index) => makeTurn(`2026-07-${String(index + 1).padStart(2, '0')}T10:00:00Z`, [1]))
session.categoryBreakdown.coding = { turns: 12, costUSD: 1, retries: 0, editTurns: 10, oneShotTurns: 5 }
const app = render(React.createElement(InteractiveDashboard, {
initialProjects: [makeProject('proj', [session])],
initialPeriod: 'today',
initialProvider: 'all',
refreshSeconds: 60,
windowColumns: 160,
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
onTestFinished(() => {
app.unmount()
vi.useRealTimers()
})
await vi.advanceTimersByTimeAsync(100)
const dashboardFrame = frames.filter(frame => frame.trim()).at(-1) ?? ''
const dashboardLines = dashboardFrame.split('\n')
expect(dashboardLines.find(line => line.includes('Daily Activity'))).toContain('By Project')
expect(dashboardLines.find(line => line.includes('Daily Activity'))).toContain('By Activity')
expect(dashboardLines.find(line => line.includes('By Model'))).toContain('MCP Servers')
expect(dashboardLines.find(line => line.includes('By Model'))).toContain('Core Tools')
expect(dashboardLines.find(line => line.includes('Shell Commands'))).toContain('Skills & Agents')
expect(dashboardFrame.match(/2026-07-/g)).toHaveLength(11)
const dailyRow = dashboardLines.find(line => /2026-07-\d{2}/.test(line)) ?? ''
const dailyBarIndex = ['█', '░'].map(char => dailyRow.indexOf(char)).filter(index => index >= 0).sort((a, b) => a - b)[0] ?? -1
expect(dailyBarIndex).toBeGreaterThanOrEqual(0)
expect(dailyBarIndex).toBeLessThan(dailyRow.search(/2026-07-\d{2}/))
const activityHeader = dashboardLines.find(line => line.includes('turns'))?.slice(106, 159) ?? ''
const activityRow = dashboardLines.find(line => line.includes('Coding'))?.slice(106, 159) ?? ''
expect(activityHeader.indexOf('cost') + 'cost'.length).toBe(activityRow.indexOf('$1.00') + '$1.00'.length)
expect(activityHeader.indexOf('turns') + 'turns'.length).toBe(activityRow.indexOf('12') + '12'.length)
expect(activityHeader.indexOf('1-shot') + '1-shot'.length).toBe(activityRow.indexOf('50%') + '50%'.length)
stdin.write('o')
// The scan does real fs work, so wait on real event-loop turns
// (setImmediate is left un-faked above) rather than counting fake-timer
// hops, bounded by a real wall-clock deadline.
const realDeadline = Date.now() + 10_000
while (!frames.some(frame => frame.includes('Savings: ~'))) {
if (Date.now() > realDeadline) {
throw new Error('Timed out waiting for the Optimize scan to render "Savings: ~"')
}
await new Promise(resolve => setImmediate(resolve))
await vi.advanceTimersByTimeAsync(50)
}
const beforeRefresh = frames.filter(frame => frame.trim()).at(-1) ?? ''
expect(beforeRefresh).toContain('CodeBurn Optimize')
expect(beforeRefresh).toContain('CodeBurn Optimize')
frames.length = 0
await vi.advanceTimersByTimeAsync(60_000)
await vi.advanceTimersByTimeAsync(100)
const frame = frames.filter(value => value.trim()).at(-1) ?? beforeRefresh
expect(frame).toBe(beforeRefresh)
expect(frame).toContain('CodeBurn Optimize')
expect(frame).toContain('CodeBurn Optimize')
expect(frame).toContain('b back')
expect(frame).not.toContain('Loading Today')
expect(frame).not.toContain('Scanning Today')
}, 30_000)
})