mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
Co-authored-by: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Co-authored-by: iamtoruk <hello@agentseal.org>
275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { readFile, rm } from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
|
|
import {
|
|
addNewDays,
|
|
DAILY_CACHE_VERSION,
|
|
type DailyCache,
|
|
type DailyEntry,
|
|
getDaysInRange,
|
|
loadDailyCache,
|
|
saveDailyCache,
|
|
withDailyCacheLock,
|
|
} from '../src/daily-cache.js'
|
|
|
|
function emptyDay(date: string, cost = 0, calls = 0): DailyEntry {
|
|
return {
|
|
date,
|
|
cost,
|
|
calls,
|
|
sessions: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
editTurns: 0,
|
|
oneShotTurns: 0,
|
|
models: {},
|
|
categories: {},
|
|
providers: {},
|
|
}
|
|
}
|
|
|
|
const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-cache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
|
|
|
beforeEach(() => {
|
|
process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
|
|
})
|
|
|
|
afterEach(async () => {
|
|
delete process.env['CODEBURN_CACHE_DIR']
|
|
if (existsSync(TMP_CACHE_ROOT)) {
|
|
await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
describe('loadDailyCache', () => {
|
|
it('returns an empty cache when the file does not exist', async () => {
|
|
const cache = await loadDailyCache()
|
|
expect(cache.version).toBe(DAILY_CACHE_VERSION)
|
|
expect(cache.lastComputedDate).toBeNull()
|
|
expect(cache.days).toEqual([])
|
|
})
|
|
|
|
it('returns an empty cache when the file contains invalid JSON', async () => {
|
|
const { writeFile, mkdir } = await import('fs/promises')
|
|
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
|
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'not valid json{{', 'utf-8')
|
|
const cache = await loadDailyCache()
|
|
expect(cache.days).toEqual([])
|
|
})
|
|
|
|
it('returns an empty cache and backs up when version is too old to migrate', async () => {
|
|
const saved = {
|
|
version: 1,
|
|
lastComputedDate: '2026-04-10',
|
|
days: [{ date: '2026-04-10', cost: 10, calls: 5 }],
|
|
}
|
|
const { writeFile, mkdir } = await import('fs/promises')
|
|
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
|
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
|
|
const cache = await loadDailyCache()
|
|
expect(cache.days).toEqual([])
|
|
expect(cache.lastComputedDate).toBeNull()
|
|
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(true)
|
|
})
|
|
|
|
it('discards a v2 cache and starts fresh (provider rollups would be stale)', async () => {
|
|
// MIN_SUPPORTED_VERSION was raised to DAILY_CACHE_VERSION because the
|
|
// migration path cannot recompute the providers / categories / models
|
|
// rollups from session data (the cache does not retain raw sessions),
|
|
// so a migrated old cache would carry forward stale provider totals
|
|
// for the full retention window. Older caches now get discarded and
|
|
// recomputed from scratch on next run.
|
|
const saved = {
|
|
version: 2,
|
|
lastComputedDate: '2026-04-10',
|
|
days: [{
|
|
date: '2026-04-10', cost: 10, calls: 5, sessions: 2,
|
|
inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100,
|
|
models: { 'claude-opus-4-6': { calls: 5, cost: 10, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100 } },
|
|
}],
|
|
}
|
|
const { writeFile, mkdir } = await import('fs/promises')
|
|
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
|
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
|
|
const cache = await loadDailyCache()
|
|
expect(cache.version).toBe(DAILY_CACHE_VERSION)
|
|
expect(cache.days).toEqual([])
|
|
expect(cache.lastComputedDate).toBeNull()
|
|
// Old cache is renamed to .v2.bak rather than deleted.
|
|
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(true)
|
|
})
|
|
|
|
it('discards a v5 cache because cached Claude costs predate 1-hour cache pricing', async () => {
|
|
const saved = {
|
|
version: 5,
|
|
lastComputedDate: '2026-05-01',
|
|
days: [{
|
|
date: '2026-05-01',
|
|
cost: 0.37575,
|
|
calls: 1,
|
|
sessions: 1,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 60_120,
|
|
editTurns: 0,
|
|
oneShotTurns: 0,
|
|
models: { 'Opus 4.7': { calls: 1, cost: 0.37575, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 60_120 } },
|
|
categories: {},
|
|
providers: { claude: { calls: 1, cost: 0.37575 } },
|
|
}],
|
|
}
|
|
const { writeFile, mkdir } = await import('fs/promises')
|
|
await mkdir(TMP_CACHE_ROOT, { recursive: true })
|
|
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
|
|
const cache = await loadDailyCache()
|
|
expect(cache.version).toBe(DAILY_CACHE_VERSION)
|
|
expect(cache.days).toEqual([])
|
|
expect(cache.lastComputedDate).toBeNull()
|
|
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(true)
|
|
})
|
|
|
|
it('round-trips a valid cache through save and load', async () => {
|
|
const saved: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
lastComputedDate: '2026-04-10',
|
|
days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
|
|
}
|
|
await saveDailyCache(saved)
|
|
const loaded = await loadDailyCache()
|
|
expect(loaded).toEqual(saved)
|
|
})
|
|
})
|
|
|
|
describe('saveDailyCache', () => {
|
|
it('writes atomically so no temp file is left after a successful save', async () => {
|
|
const saved: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
lastComputedDate: '2026-04-10',
|
|
days: [emptyDay('2026-04-10', 5)],
|
|
}
|
|
await saveDailyCache(saved)
|
|
const { readdir } = await import('fs/promises')
|
|
const files = await readdir(TMP_CACHE_ROOT)
|
|
const tempLeftovers = files.filter(f => f.endsWith('.tmp'))
|
|
expect(tempLeftovers).toEqual([])
|
|
const finalFile = await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'utf-8')
|
|
expect(JSON.parse(finalFile)).toEqual(saved)
|
|
})
|
|
})
|
|
|
|
describe('addNewDays', () => {
|
|
it('returns a new cache with the added days sorted ascending by date', () => {
|
|
const base: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
lastComputedDate: '2026-04-08',
|
|
days: [emptyDay('2026-04-07', 3), emptyDay('2026-04-08', 5)],
|
|
}
|
|
const updated = addNewDays(base, [emptyDay('2026-04-10', 9), emptyDay('2026-04-09', 7)], '2026-04-10')
|
|
expect(updated.days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09', '2026-04-10'])
|
|
expect(updated.lastComputedDate).toBe('2026-04-10')
|
|
})
|
|
|
|
it('replaces existing days with incoming data (last write wins)', () => {
|
|
const base: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
lastComputedDate: '2026-04-08',
|
|
days: [emptyDay('2026-04-08', 5)],
|
|
}
|
|
const updated = addNewDays(base, [emptyDay('2026-04-08', 99)], '2026-04-08')
|
|
const aprilEight = updated.days.find(d => d.date === '2026-04-08')!
|
|
expect(aprilEight.cost).toBe(99)
|
|
})
|
|
|
|
it('does not regress lastComputedDate if incoming newestDate is older', () => {
|
|
const base: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
lastComputedDate: '2026-04-10',
|
|
days: [emptyDay('2026-04-10', 5)],
|
|
}
|
|
const updated = addNewDays(base, [emptyDay('2026-04-05', 3)], '2026-04-05')
|
|
expect(updated.lastComputedDate).toBe('2026-04-10')
|
|
})
|
|
|
|
it('skips prune when newestDate is malformed (does not silently drop all days)', () => {
|
|
// Regression guard: a corrupt newestDate string used to produce a NaN
|
|
// cutoff, which made `d.date >= "Invalid Date"` always false and
|
|
// wiped every cached day on the next merge. The guard now leaves
|
|
// the entries untouched so the next valid run can prune normally.
|
|
const base: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
lastComputedDate: '2026-04-10',
|
|
days: [emptyDay('2026-04-08', 1), emptyDay('2026-04-09', 2), emptyDay('2026-04-10', 3)],
|
|
}
|
|
const updated = addNewDays(base, [], 'not-a-date')
|
|
expect(updated.days.map(d => d.date)).toEqual(['2026-04-08', '2026-04-09', '2026-04-10'])
|
|
})
|
|
|
|
it('still prunes when newestDate is valid', () => {
|
|
const old = '2020-01-01'
|
|
const recent = '2026-04-10'
|
|
const base: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
lastComputedDate: recent,
|
|
days: [emptyDay(old, 1), emptyDay(recent, 2)],
|
|
}
|
|
const updated = addNewDays(base, [], recent)
|
|
// 730-day retention from 2026-04-10 → cutoff ~2024-04-11; 2020-01-01 must be gone.
|
|
expect(updated.days.find(d => d.date === old)).toBeUndefined()
|
|
expect(updated.days.find(d => d.date === recent)).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('getDaysInRange', () => {
|
|
const cache: DailyCache = {
|
|
version: DAILY_CACHE_VERSION,
|
|
lastComputedDate: '2026-04-10',
|
|
days: [
|
|
emptyDay('2026-04-05', 1),
|
|
emptyDay('2026-04-06', 2),
|
|
emptyDay('2026-04-07', 3),
|
|
emptyDay('2026-04-08', 4),
|
|
emptyDay('2026-04-09', 5),
|
|
emptyDay('2026-04-10', 6),
|
|
],
|
|
}
|
|
|
|
it('returns inclusive start and end range', () => {
|
|
const days = getDaysInRange(cache, '2026-04-07', '2026-04-09')
|
|
expect(days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09'])
|
|
})
|
|
|
|
it('returns empty when range is entirely outside cache', () => {
|
|
expect(getDaysInRange(cache, '2026-03-01', '2026-03-10')).toEqual([])
|
|
expect(getDaysInRange(cache, '2026-05-01', '2026-05-10')).toEqual([])
|
|
})
|
|
|
|
it('clips to available cache days when range extends beyond', () => {
|
|
const days = getDaysInRange(cache, '2026-04-09', '2026-04-20')
|
|
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
|
|
})
|
|
})
|
|
|
|
describe('withDailyCacheLock', () => {
|
|
it('serializes concurrent operations', async () => {
|
|
const sequence: string[] = []
|
|
const op = async (tag: string): Promise<void> => {
|
|
await withDailyCacheLock(async () => {
|
|
sequence.push(`start-${tag}`)
|
|
await new Promise(r => setTimeout(r, 20))
|
|
sequence.push(`end-${tag}`)
|
|
})
|
|
}
|
|
await Promise.all([op('a'), op('b'), op('c')])
|
|
for (let i = 0; i < sequence.length; i += 2) {
|
|
expect(sequence[i]?.startsWith('start-')).toBe(true)
|
|
expect(sequence[i + 1]?.startsWith('end-')).toBe(true)
|
|
expect(sequence[i]!.slice(6)).toBe(sequence[i + 1]!.slice(4))
|
|
}
|
|
})
|
|
})
|