mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
* fix: fix and improve test isolation and collision with environment * docs: remove unnecessary comment * test(env-isolation): clear CODEBURN_FORCE_MACOS_MAJOR and pin TZ Two env vars read in src/ were not isolated: CODEBURN_FORCE_MACOS_MAJOR (now cleared so it cannot leak between tests) and TZ (now pinned to UTC, since clearing it falls back to the OS zone and would shift date buckets versus a clean CI runner). --------- Co-authored-by: AgentSeal <hello@agentseal.org>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import { setProxyPaths, isProxiedPath, getProxyPathsConfigHash, setLocalModelSavings, setModelAliases, loadPricing } from '../src/models.js'
|
|
import { parseAllSessions, clearSessionCache, filterProjectsByDateRange } from '../src/parser.js'
|
|
import type { DateRange, ProjectSummary } from '../src/types.js'
|
|
|
|
// ── Part A: isProxiedPath matching rule (pure) ─────────────────────────────
|
|
|
|
describe('isProxiedPath: path matching rule', () => {
|
|
beforeEach(() => setProxyPaths([]))
|
|
afterEach(() => setProxyPaths([]))
|
|
|
|
it('never matches when no proxy paths are configured', () => {
|
|
expect(isProxiedPath('/Users/me/work/acme')).toBe(false)
|
|
})
|
|
|
|
it('matches an exact path', () => {
|
|
setProxyPaths(['/Users/me/work/acme'])
|
|
expect(isProxiedPath('/Users/me/work/acme')).toBe(true)
|
|
})
|
|
|
|
it('matches a child directory under the prefix', () => {
|
|
setProxyPaths(['/Users/me/work'])
|
|
expect(isProxiedPath('/Users/me/work/acme/sub')).toBe(true)
|
|
})
|
|
|
|
it('does NOT match across a partial path segment (boundary guard)', () => {
|
|
// The single most important negative: a string prefix that is not a
|
|
// directory-segment boundary must not silently zero unrelated spend.
|
|
setProxyPaths(['/Users/me/proj'])
|
|
expect(isProxiedPath('/Users/me/project-unrelated')).toBe(false)
|
|
})
|
|
|
|
it('is tolerant of trailing slashes on both config and cwd', () => {
|
|
setProxyPaths(['/Users/me/work/'])
|
|
expect(isProxiedPath('/Users/me/work')).toBe(true)
|
|
expect(isProxiedPath('/Users/me/work/')).toBe(true)
|
|
})
|
|
|
|
it('is case-insensitive (macOS/Windows default filesystems)', () => {
|
|
setProxyPaths(['/Users/Me/Work'])
|
|
expect(isProxiedPath('/users/me/work/acme')).toBe(true)
|
|
})
|
|
|
|
it('matches a Windows-style config against a forward-slash cwd', () => {
|
|
setProxyPaths(['C:\\Users\\me\\work'])
|
|
expect(isProxiedPath('C:/Users/me/work/acme')).toBe(true)
|
|
})
|
|
|
|
it('never matches an empty/undefined/null cwd', () => {
|
|
setProxyPaths(['/Users/me/work'])
|
|
expect(isProxiedPath('')).toBe(false)
|
|
expect(isProxiedPath(undefined)).toBe(false)
|
|
expect(isProxiedPath(null)).toBe(false)
|
|
})
|
|
|
|
it('drops a root "/" entry so it can never match everything', () => {
|
|
setProxyPaths(['/'])
|
|
expect(isProxiedPath('/Users/me/anything')).toBe(false)
|
|
})
|
|
|
|
it('drops blank / non-string entries', () => {
|
|
setProxyPaths(['', ' ', undefined as unknown as string, '/Users/me/work'])
|
|
expect(isProxiedPath('/Users/me/work/x')).toBe(true)
|
|
expect(isProxiedPath('/somewhere/else')).toBe(false)
|
|
})
|
|
|
|
it('matches when any one of several configured paths matches', () => {
|
|
setProxyPaths(['/Users/me/a', '/Users/me/b'])
|
|
expect(isProxiedPath('/Users/me/b/deep')).toBe(true)
|
|
expect(isProxiedPath('/Users/me/c')).toBe(false)
|
|
})
|
|
|
|
it('is reset by setProxyPaths([])', () => {
|
|
setProxyPaths(['/Users/me/work'])
|
|
expect(isProxiedPath('/Users/me/work')).toBe(true)
|
|
setProxyPaths([])
|
|
expect(isProxiedPath('/Users/me/work')).toBe(false)
|
|
})
|
|
|
|
it('matches a leading-slash-stripped cwd (non-Claude provider path form)', () => {
|
|
// Codex/unsanitizePath project paths drop the leading slash; the configured
|
|
// path keeps it. Matching must be agnostic to that difference.
|
|
setProxyPaths(['/Users/me/work'])
|
|
expect(isProxiedPath('Users/me/work/acme')).toBe(true)
|
|
expect(isProxiedPath('Users/me/work')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('getProxyPathsConfigHash: cache-key stability', () => {
|
|
beforeEach(() => setProxyPaths([]))
|
|
afterEach(() => setProxyPaths([]))
|
|
|
|
it('is empty when unconfigured', () => {
|
|
expect(getProxyPathsConfigHash()).toBe('')
|
|
})
|
|
|
|
it('is order-independent', () => {
|
|
setProxyPaths(['/a', '/b'])
|
|
const h1 = getProxyPathsConfigHash()
|
|
setProxyPaths(['/b', '/a'])
|
|
expect(getProxyPathsConfigHash()).toBe(h1)
|
|
})
|
|
|
|
it('does NOT collide two materially different sets (delimited join)', () => {
|
|
// Regression guard: a separator-less join would make {'/a','/b'} and
|
|
// {'/a/b'} hash identically and let the session cache serve stale numbers.
|
|
setProxyPaths(['/a', '/b'])
|
|
const h1 = getProxyPathsConfigHash()
|
|
setProxyPaths(['/a/b'])
|
|
expect(getProxyPathsConfigHash()).not.toBe(h1)
|
|
})
|
|
})
|
|
|
|
// ── Part B: end-to-end attribution through parseAllSessions ────────────────
|
|
|
|
const FIXTURE_DAY = Date.UTC(2026, 3, 16)
|
|
const RANGE_START = new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000)
|
|
const RANGE_END = new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000)
|
|
const makeRange = (): DateRange => ({ start: RANGE_START, end: RANGE_END })
|
|
|
|
// A stable, non-existent absolute path: resolveCanonicalProjectPath finds no
|
|
// .git ancestor and returns it unchanged, so projectPath is predictable.
|
|
const FIXTURE_CWD = '/private/var/eywa-proxy-fixture/acme'
|
|
|
|
let tmpDirs: string[] = []
|
|
|
|
beforeAll(async () => {
|
|
await loadPricing()
|
|
})
|
|
|
|
beforeEach(() => {
|
|
setProxyPaths([])
|
|
setLocalModelSavings({})
|
|
setModelAliases({})
|
|
clearSessionCache()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
setProxyPaths([])
|
|
clearSessionCache()
|
|
while (tmpDirs.length > 0) {
|
|
const d = tmpDirs.pop()
|
|
if (d) await rm(d, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
async function setupProxiedSession(cwd: string = FIXTURE_CWD): Promise<void> {
|
|
const base = await mkdtemp(join(tmpdir(), 'codeburn-proxy-'))
|
|
tmpDirs.push(base)
|
|
const projectDir = join(base, 'projects', 'p')
|
|
await mkdir(projectDir, { recursive: true })
|
|
const line = JSON.stringify({
|
|
type: 'assistant',
|
|
timestamp: '2026-04-16T10:00:00.000Z',
|
|
sessionId: 's1',
|
|
cwd,
|
|
message: {
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'claude-sonnet-4-6',
|
|
id: 'msg-1',
|
|
content: [],
|
|
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
},
|
|
})
|
|
await writeFile(join(projectDir, 's1.jsonl'), line + '\n', 'utf-8')
|
|
process.env['CLAUDE_CONFIG_DIR'] = base
|
|
}
|
|
|
|
const allCalls = (projects: ProjectSummary[]) =>
|
|
projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
|
|
|
|
describe('proxy pricing: end-to-end through parseAllSessions', () => {
|
|
it('attributes nothing as proxied when no proxy paths are configured', async () => {
|
|
await setupProxiedSession()
|
|
const projects = await parseAllSessions(makeRange(), 'all')
|
|
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
|
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
|
|
expect(total).toBeGreaterThan(0)
|
|
expect(proxied).toBe(0)
|
|
})
|
|
|
|
it('flags the full cost as proxied when the project is under a proxy path, WITHOUT altering costUSD', async () => {
|
|
await setupProxiedSession()
|
|
setProxyPaths([FIXTURE_CWD])
|
|
clearSessionCache()
|
|
const projects = await parseAllSessions(makeRange(), 'all')
|
|
|
|
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
|
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
|
|
expect(total).toBeGreaterThan(0)
|
|
// "Full cost, flagged": the billable figure is preserved, the same amount
|
|
// is reported as subscription-covered, so net out-of-pocket is 0.
|
|
expect(proxied).toBeCloseTo(total, 10)
|
|
expect(total - proxied).toBeCloseTo(0, 10)
|
|
|
|
// The raw per-call cost is never destroyed — it stays at the full API rate.
|
|
const calls = allCalls(projects)
|
|
expect(calls.length).toBeGreaterThan(0)
|
|
for (const c of calls) expect(c.costUSD).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('matches a parent prefix on a segment boundary', async () => {
|
|
await setupProxiedSession()
|
|
setProxyPaths(['/private/var/eywa-proxy-fixture'])
|
|
clearSessionCache()
|
|
const projects = await parseAllSessions(makeRange(), 'all')
|
|
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
|
|
expect(proxied).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('does NOT flag a sibling path that is only a string prefix (no spend silently zeroed)', async () => {
|
|
await setupProxiedSession()
|
|
// '/private/var/eywa-proxy-fixture/ac' is a string prefix of '.../acme'
|
|
// but not a directory-segment boundary — must not match.
|
|
setProxyPaths(['/private/var/eywa-proxy-fixture/ac'])
|
|
clearSessionCache()
|
|
const projects = await parseAllSessions(makeRange(), 'all')
|
|
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
|
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
|
|
expect(total).toBeGreaterThan(0)
|
|
expect(proxied).toBe(0)
|
|
})
|
|
|
|
it('attributes nothing when a different, unrelated path is configured', async () => {
|
|
await setupProxiedSession()
|
|
setProxyPaths(['/Users/someone/else'])
|
|
clearSessionCache()
|
|
const projects = await parseAllSessions(makeRange(), 'all')
|
|
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
|
|
expect(proxied).toBe(0)
|
|
})
|
|
|
|
it('preserves proxy attribution after date-range filtering (filterProjectsByDateRange)', async () => {
|
|
await setupProxiedSession()
|
|
setProxyPaths([FIXTURE_CWD])
|
|
clearSessionCache()
|
|
const projects = await parseAllSessions(makeRange(), 'all')
|
|
const filtered = filterProjectsByDateRange(projects, makeRange())
|
|
expect(filtered.length).toBeGreaterThan(0)
|
|
const total = filtered.reduce((s, p) => s + p.totalCostUSD, 0)
|
|
const proxied = filtered.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
|
|
expect(total).toBeGreaterThan(0)
|
|
expect(proxied).toBeCloseTo(total, 10)
|
|
})
|
|
|
|
it('does not serve stale proxy attribution from the in-memory cache after proxyPaths changes', async () => {
|
|
// parseAllSessions caches ProjectSummary[] for 180s keyed partly on the
|
|
// proxy-config hash. Toggling proxyPaths must change the key so the second
|
|
// call recomputes rather than returning the pre-change (proxied=0) result.
|
|
await setupProxiedSession()
|
|
const before = await parseAllSessions(makeRange(), 'all')
|
|
expect(before.reduce((s, p) => s + p.totalProxiedCostUSD, 0)).toBe(0)
|
|
|
|
setProxyPaths([FIXTURE_CWD]) // deliberately NO clearSessionCache()
|
|
const after = await parseAllSessions(makeRange(), 'all')
|
|
const proxied = after.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
|
|
expect(proxied).toBeGreaterThan(0)
|
|
})
|
|
})
|