mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
The Optimize scan does real fs I/O (readdir/stat) that only resolves on a real event-loop turn, but the wait loop counted 20 vi.advanceTimersByTimeAsync hops under full fake timers, which flush fake timers + microtasks but never give real I/O a chance to complete. Under load that read a stale "Scanning Today..." frame. Scope fake timers to just what the 60s auto-refresh interval needs, leave setImmediate/Date real, and wait on a real wall-clock deadline instead of a fixed hop count.
748 lines
30 KiB
TypeScript
748 lines
30 KiB
TypeScript
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 1–14 of 37 days scanned · newest first')
|
||
})
|
||
|
||
it('clamps the visible end to the row count', () => {
|
||
expect(dailyActivityFooter(30, 14, 37)).toBe('Showing 31–37 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('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('Token estimates are approximate.'))) {
|
||
if (Date.now() > realDeadline) {
|
||
throw new Error('Timed out waiting for the Optimize scan to render "Token estimates are approximate."')
|
||
}
|
||
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('Token estimates are approximate.')
|
||
|
||
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('Token estimates are approximate.')
|
||
expect(frame).toContain('b back')
|
||
expect(frame).not.toContain('Loading Today')
|
||
expect(frame).not.toContain('Scanning Today')
|
||
|
||
}, 30_000)
|
||
})
|