chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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, exportJson, 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')
|
||||
})
|
||||
|
||||
it('writes MCP server usage to mcp.csv', async () => {
|
||||
const project = makeProject('app')
|
||||
project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 5 } }
|
||||
const periods: PeriodExport[] = [{ label: '30 Days', projects: [project] }]
|
||||
|
||||
const folder = await exportCsv(periods, join(tmpDir, 'mcp.csv'))
|
||||
const mcp = await readFile(join(folder, 'mcp.csv'), 'utf-8')
|
||||
|
||||
expect(mcp).toContain('Server,Calls,Share (%)')
|
||||
expect(mcp).toContain('node_repl,5,100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportJson', () => {
|
||||
it('includes an mcp section with per-server usage', async () => {
|
||||
const project = makeProject('app')
|
||||
project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 3 }, github: { calls: 1 } }
|
||||
const periods: PeriodExport[] = [{ label: '30 Days', projects: [project] }]
|
||||
|
||||
const outputPath = join(tmpDir, 'export.json')
|
||||
const saved = await exportJson(periods, outputPath)
|
||||
const data = JSON.parse(await readFile(saved, 'utf-8'))
|
||||
|
||||
expect(Array.isArray(data.mcp)).toBe(true)
|
||||
expect(data.mcp).toEqual([
|
||||
{ Server: 'node_repl', Calls: 3, 'Share (%)': 75 },
|
||||
{ Server: 'github', Calls: 1, 'Share (%)': 25 },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user