mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 07:09:41 +00:00
os.homedir() reads USERPROFILE on Windows and never HOME, so the isolation setup and every test that moved home were still resolving the developer's real home directory. Fixtures came back empty, real sessions leaked into counts, and the code under test wrote plans, receipts and a sync ledger into the real ~/.config/codeburn during a run. USERPROFILE now joins the redirected variables and a shared setHome() helper moves HOME and USERPROFILE together; every spawn env carries both. The remaining Windows-only failures were POSIX-shaped fixtures: chmod tricks that only toggle a read-only attribute on NTFS, pid 1 as an always-alive lock holder, splitting paths on '/', bare tar, mode-bit expectations, and per-file timeouts too short for the cost of a tsx spawn. Each gets a platform-aware equivalent so nothing is skipped on Windows and the POSIX branches stay byte-for-byte what they were. Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
154 lines
6.2 KiB
TypeScript
154 lines
6.2 KiB
TypeScript
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<string> {
|
|
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, USERPROFILE: 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<Record<string, unknown>> {
|
|
return JSON.parse(await readFile(configPath(home), 'utf-8'))
|
|
}
|
|
async function writeConfig(home: string, obj: unknown): Promise<void> {
|
|
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<void> {
|
|
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)
|
|
})
|
|
})
|