diff --git a/src/export.ts b/src/export.ts index 2ef75bd..4e1afc1 100644 --- a/src/export.ts +++ b/src/export.ts @@ -6,7 +6,7 @@ import { getCurrency, convertCost } from './currency.js' import { dateKey } from './day-aggregator.js' function escCsv(s: string): string { - const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s + const sanitized = /^[\t\r=+\-@]/.test(s) ? `'${s}` : s if (sanitized.includes(',') || sanitized.includes('"') || sanitized.includes('\n')) { return `"${sanitized.replace(/"/g, '""')}"` } @@ -283,7 +283,7 @@ async function clearCodeburnExportFolder(path: string): Promise { /// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally). export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise { const thirtyDays = periods.find(p => p.label === '30 Days') - const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects + const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? [] let folder = resolve(outputPath) if (folder.toLowerCase().endsWith('.csv')) { @@ -325,7 +325,7 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr export async function exportJson(periods: PeriodExport[], outputPath: string): Promise { const thirtyDays = periods.find(p => p.label === '30 Days') - const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects + const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? [] const { code, rate, symbol } = getCurrency() const data = { diff --git a/src/fs-utils.ts b/src/fs-utils.ts index 49eff20..823a630 100644 --- a/src/fs-utils.ts +++ b/src/fs-utils.ts @@ -89,5 +89,7 @@ export async function* readSessionLines(filePath: string): AsyncGenerator { 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('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) + }) }) diff --git a/tests/fs-utils.test.ts b/tests/fs-utils.test.ts index 7c5f38b..6510900 100644 --- a/tests/fs-utils.test.ts +++ b/tests/fs-utils.test.ts @@ -7,6 +7,7 @@ import { MAX_SESSION_FILE_BYTES, STREAM_THRESHOLD_BYTES, readSessionFile, + readSessionLines, } from '../src/fs-utils.js' describe('readSessionFile', () => { @@ -61,3 +62,37 @@ describe('readSessionFile', () => { expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull() }) }) + +describe('readSessionLines', () => { + const tmpDirs: string[] = [] + + afterEach(async () => { + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } + }) + + async function tmpPath(content: string): Promise { + const base = await mkdtemp(join(tmpdir(), 'codeburn-lines-')) + tmpDirs.push(base) + const p = join(base, 'session.jsonl') + await writeFile(p, content) + return p + } + + it('yields all lines from a file', async () => { + const p = await tmpPath('line1\nline2\nline3\n') + const lines: string[] = [] + for await (const line of readSessionLines(p)) lines.push(line) + expect(lines).toEqual(['line1', 'line2', 'line3']) + }) + + it('does not leak file descriptors when generator is abandoned early', async () => { + const content = Array.from({ length: 1000 }, (_, i) => `line-${i}`).join('\n') + const p = await tmpPath(content) + const gen = readSessionLines(p) + await gen.next() + await gen.return(undefined) + }) +}) diff --git a/tests/models.test.ts b/tests/models.test.ts new file mode 100644 index 0000000..71f944e --- /dev/null +++ b/tests/models.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeAll } from 'vitest' + +import { getModelCosts, getShortModelName, loadPricing } from '../src/models.js' + +beforeAll(async () => { + await loadPricing() +}) + +describe('getModelCosts', () => { + it('does not match short canonical against longer pricing key', () => { + const costs = getModelCosts('gpt-4') + if (costs) { + expect(costs.inputCostPerToken).not.toBe(2.5e-6) + } + }) + + it('returns correct pricing for gpt-4o vs gpt-4o-mini', () => { + const mini = getModelCosts('gpt-4o-mini') + const full = getModelCosts('gpt-4o') + expect(mini).not.toBeNull() + expect(full).not.toBeNull() + expect(mini!.inputCostPerToken).toBeLessThan(full!.inputCostPerToken) + }) + + it('returns fallback pricing for known Claude models', () => { + const costs = getModelCosts('claude-opus-4-6-20260205') + expect(costs).not.toBeNull() + expect(costs!.inputCostPerToken).toBe(5e-6) + }) +}) + +describe('getShortModelName', () => { + it('maps gpt-4o-mini correctly (not gpt-4o)', () => { + expect(getShortModelName('gpt-4o-mini-2024-07-18')).toBe('GPT-4o Mini') + }) + + it('maps gpt-4o correctly', () => { + expect(getShortModelName('gpt-4o-2024-08-06')).toBe('GPT-4o') + }) + + it('maps gpt-4.1-mini correctly (not gpt-4.1)', () => { + expect(getShortModelName('gpt-4.1-mini-2025-04-14')).toBe('GPT-4.1 Mini') + }) + + it('maps gpt-5.4-mini correctly (not gpt-5.4)', () => { + expect(getShortModelName('gpt-5.4-mini')).toBe('GPT-5.4 Mini') + }) + + it('maps claude-opus-4-6 with date suffix', () => { + expect(getShortModelName('claude-opus-4-6-20260205')).toBe('Opus 4.6') + }) +})