import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { afterEach, describe, expect, it, vi } from 'vitest' // Each test spawns `tsx src/cli.ts` several times, which re-transpiles the CLI // on every spawn. Under full-suite parallel load those spawns contend for CPU // and can exceed the 5s default, so give this spawn-based file a timeout that // matches the per-spawn cap below. The slowest test runs ~1.7s in isolation. vi.setConfig({ testTimeout: 30_000 }) let homes: string[] = [] afterEach(async () => { while (homes.length > 0) { const h = homes.pop() if (h) await rm(h, { recursive: true, force: true }) } }) async function makeHome(): Promise { const home = await mkdtemp(join(tmpdir(), 'codeburn-proxy-cli-')) homes.push(home) return home } function runCli(args: string[], home: string) { return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000, }) } const configPath = (home: string) => join(home, '.config', 'codeburn', 'config.json') async function readConfig(home: string): Promise> { return JSON.parse(await readFile(configPath(home), 'utf-8')) } async function writeConfig(home: string, obj: unknown): Promise { await mkdir(join(home, '.config', 'codeburn'), { recursive: true }) await writeFile(configPath(home), JSON.stringify(obj), 'utf-8') } describe('codeburn proxy-path CLI', () => { it('adds, lists, dedupes, and removes a proxy path', async () => { const home = await makeHome() expect(runCli(['proxy-path', '--list'], home).stdout).toContain('No proxy paths configured') const add = runCli(['proxy-path', '/work/copilot-repo'], home) expect(add.status).toBe(0) expect(add.stdout).toContain('Proxy path saved') expect((await readConfig(home)).proxyPaths).toEqual(['/work/copilot-repo']) // Trailing-slash variant is the same path -> deduped, not a second entry. const dup = runCli(['proxy-path', '/work/copilot-repo/'], home) expect(dup.stdout).toContain('already configured') expect((await readConfig(home)).proxyPaths).toEqual(['/work/copilot-repo']) expect(runCli(['proxy-path', '--list'], home).stdout).toContain('/work/copilot-repo') const rm = runCli(['proxy-path', '--remove', '/work/copilot-repo'], home) expect(rm.status).toBe(0) expect((await readConfig(home)).proxyPaths).toBeUndefined() }) it('rejects a relative path and the filesystem root', async () => { const home = await makeHome() const rel = runCli(['proxy-path', './rel'], home) expect(rel.status).toBe(1) expect(rel.stderr).toContain('absolute') const root = runCli(['proxy-path', '/'], home) expect(root.status).toBe(1) }) it('errors (exit 1) when removing a path that is not configured', async () => { const home = await makeHome() const res = runCli(['proxy-path', '--remove', '/not/configured'], home) expect(res.status).toBe(1) expect(res.stderr).toContain('No proxy path found') }) it('does not crash on a malformed config (proxyPaths as a non-array)', async () => { const home = await makeHome() await writeConfig(home, { proxyPaths: 42 }) const list = runCli(['proxy-path', '--list'], home) expect(list.status).toBe(0) expect(list.stderr).not.toMatch(/TypeError|is not iterable|is not a function/) expect(list.stdout).toContain('No proxy paths configured') const add = runCli(['proxy-path', '/work/repo'], home) expect(add.status).toBe(0) expect(add.stderr).not.toMatch(/TypeError/) // The garbage was discarded; only the valid absolute path persists. expect((await readConfig(home)).proxyPaths).toEqual(['/work/repo']) }) it('does not crash on a malformed config (proxyPaths array with non-string entries)', async () => { const home = await makeHome() await writeConfig(home, { proxyPaths: [42, null, '/work/keep'] }) const add = runCli(['proxy-path', '/work/new'], home) expect(add.status).toBe(0) expect(add.stderr).not.toMatch(/TypeError|is not a function/) expect((await readConfig(home)).proxyPaths).toEqual(['/work/keep', '/work/new']) }) }) describe('codeburn report --format json: proxy overview', () => { async function writeClaudeSession(home: string, cwd: string): Promise { const dir = join(home, '.claude', 'projects', 'proxied') await mkdir(dir, { recursive: true }) const ts = new Date().toISOString() const line = JSON.stringify({ type: 'assistant', sessionId: 's1', timestamp: ts, cwd, message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'hi' }], usage: { input_tokens: 10000, output_tokens: 2000, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, }, }) await writeFile(join(dir, 's1.jsonl'), line + '\n', 'utf-8') } function overview(home: string): { cost: number; proxiedCost: number; netCost: number } { const res = runCli(['report', '--period', 'all', '--format', 'json'], home) expect(res.status).toBe(0) return JSON.parse(res.stdout).overview } it('reports proxiedCost == cost and netCost == 0 under a proxy path, and netCost == cost without one', async () => { const home = await makeHome() const cwd = '/proj/proxiedrepo' await writeClaudeSession(home, cwd) // Baseline: no proxy config -> nothing attributed, net == cost. const base = overview(home) expect(base.cost).toBeGreaterThan(0) expect(base.proxiedCost).toBe(0) expect(base.netCost).toBeCloseTo(base.cost, 10) // With the proxy path -> full cost preserved, fully subscription-covered. await writeConfig(home, { proxyPaths: [cwd] }) const proxied = overview(home) expect(proxied.cost).toBeCloseTo(base.cost, 10) // cost is never modified expect(proxied.proxiedCost).toBeCloseTo(proxied.cost, 10) expect(proxied.netCost).toBeCloseTo(0, 10) expect(proxied.netCost).toBeCloseTo(proxied.cost - proxied.proxiedCost, 10) }) })