codeburn/tests/export.test.ts
Resham Joshi be6068b244
feat(report): add per-model efficiency metrics
Adds per-model efficiency metrics (edit turns, one-shot rate, retries/edit, cost/edit) to the TUI By Model panel, JSON report output, and CSV export. Closes item 4 of #12. Supersedes #226 with review fixes (units rename, min-sample guard in TUI, tighter <synthetic> filter, multi-model attribution test). Original implementation by @ozymandiashh.
2026-05-05 23:36:59 -07:00

196 lines
6.6 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, readFile, readdir, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { exportCsv, type PeriodExport } from '../src/export.js'
import type { ProjectSummary } from '../src/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'export-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function makeProject(projectPath: string): ProjectSummary {
return {
project: projectPath,
projectPath,
sessions: [
{
sessionId: 'sess-001',
project: projectPath,
firstTimestamp: '2026-04-14T10:00:00Z',
lastTimestamp: '2026-04-14T10:01:00Z',
totalCostUSD: 1.23,
totalInputTokens: 100,
totalOutputTokens: 50,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [
{
userMessage: '=SUM(1,2)',
timestamp: '2026-04-14T10:00:00Z',
sessionId: 'sess-001',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [
{
provider: 'claude',
model: '+danger-model',
usage: {
inputTokens: 100,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 1.23,
tools: ['Read'],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-04-14T10:00:00Z',
bashCommands: ['@malicious'],
deduplicationKey: 'dedup-1',
},
],
},
],
modelBreakdown: {
'+danger-model': {
calls: 1,
costUSD: 1.23,
tokens: {
inputTokens: 100,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
},
},
toolBreakdown: {
Read: { calls: 1 },
},
mcpBreakdown: {},
bashBreakdown: {
'@malicious': { calls: 1 },
},
categoryBreakdown: {
coding: { turns: 1, costUSD: 1.23, retries: 0, editTurns: 1, oneShotTurns: 1 },
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 },
},
skillBreakdown: {},
},
],
totalCostUSD: 1.23,
totalApiCalls: 1,
}
}
describe('exportCsv', () => {
it('prefixes formula-like cells to prevent CSV injection', async () => {
const periods: PeriodExport[] = [
{
label: '30 Days',
projects: [makeProject('=cmd,calc')],
},
]
const outputPath = join(tmpDir, 'report.csv')
const folder = await exportCsv(periods, outputPath)
// exportCsv now writes a folder of clean one-table-per-file CSVs, so the formula-prefix
// guard is scattered across files. Concatenate them for the assertion surface.
const [projects, models, shell] = await Promise.all([
readFile(join(folder, 'projects.csv'), 'utf-8'),
readFile(join(folder, 'models.csv'), 'utf-8'),
readFile(join(folder, 'shell-commands.csv'), 'utf-8'),
])
const content = projects + models + shell
expect(content).toContain("\"'=cmd,calc\"")
expect(content).toContain("'+danger-model")
expect(content).toContain("'@malicious")
})
it('escapes tab and carriage-return prefixes in CSV cells', async () => {
const periods: PeriodExport[] = [
{
label: '30 Days',
projects: [makeProject('\tcmd'), makeProject('\rcmd')],
},
]
const outputPath = join(tmpDir, 'tab-cr.csv')
const folder = await exportCsv(periods, outputPath)
const projects = await readFile(join(folder, 'projects.csv'), 'utf-8')
expect(projects).toContain("'\tcmd")
expect(projects).toContain("'\rcmd")
})
it('includes per-model efficiency metrics', async () => {
const periods: PeriodExport[] = [
{
label: '30 Days',
projects: [makeProject('app')],
},
]
const outputPath = join(tmpDir, 'models.csv')
const folder = await exportCsv(periods, outputPath)
const models = await readFile(join(folder, 'models.csv'), 'utf-8')
expect(models).toContain('Edit Turns')
expect(models).toContain('One-shot Rate (%)')
expect(models).toContain('Retries/Edit')
expect(models).toContain('Cost/Edit')
expect(models).toContain(',1,100,0,')
})
it('does not crash when periods array is empty', async () => {
const outputPath = join(tmpDir, 'empty.csv')
const folder = await exportCsv([], outputPath)
const entries = await readdir(folder)
expect(entries.length).toBeGreaterThanOrEqual(0)
})
it('describes detail files without hardcoding a 30-day window', async () => {
const periods: PeriodExport[] = [
{
label: '2026-04-07 to 2026-04-10',
projects: [makeProject('app')],
},
]
const outputPath = join(tmpDir, 'custom.csv')
const folder = await exportCsv(periods, outputPath)
const readme = await readFile(join(folder, 'README.txt'), 'utf-8')
expect(readme).toContain('selected detail period')
expect(readme).not.toContain('30-day window')
})
})