import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { spawn, type ChildProcess } from 'child_process' import { mkdir, readFile, writeFile } from 'fs/promises' import { join } from 'path' import { classifyRootReuse, createOutputMemoEntry } from '../src/serve.js' it('timestamps a completed output memo before parsing begins', () => { const parseStartedAt = 100 const rootEventDuringParseAt = 150 const parseCompletedAt = 200 const memo = createOutputMemoEntry(parseStartedAt, parseCompletedAt, 'output', 'config') const rootsQuietSince = (sinceTs: number): boolean => rootEventDuringParseAt < sinceTs // The old completion timestamp incorrectly made the in-parse event look // older than the memo. The start timestamp keeps it visible to validation. expect(rootsQuietSince(parseCompletedAt)).toBe(true) expect(memo.createdAt).toBe(parseCompletedAt) expect(memo.validatedFrom).toBe(parseStartedAt) expect(rootsQuietSince(memo.validatedFrom)).toBe(false) }) it('classifies watcher gaps as unknown without confusing them with dirty roots', () => { expect(classifyRootReuse(100, { startedAt: 50, lastEventAt: 0, healthy: false })).toBe('unknown') expect(classifyRootReuse(100, { startedAt: 150, lastEventAt: 0, healthy: true })).toBe('unknown') expect(classifyRootReuse(100, { startedAt: 50, lastEventAt: 100, healthy: false })).toBe('dirty') expect(classifyRootReuse(100, { startedAt: 50, lastEventAt: 100, healthy: true })).toBe('dirty') expect(classifyRootReuse(100, { startedAt: 50, lastEventAt: 99, healthy: true })).toBe('clean') }) // End-to-end protocol test for `codeburn serve --stdio` (the desktop app's // resident query server). Runs the real entry through tsx against the // test-isolated env (env-isolation.ts points every provider at empty dirs), // so requests answer fast and deterministically empty. describe('codeburn serve --stdio', () => { let child: ChildProcess let buffer = '' const waiters = new Map) => void>() const progressFrames = new Map>>() let configPath = '' let readyResolve: () => void const ready = new Promise(resolve => { readyResolve = resolve }) function request(id: number, args: string[]): Promise> { return new Promise(resolve => { waiters.set(id, resolve) child.stdin!.write(JSON.stringify({ id, args }) + '\n') }) } function sendRaw(line: string): void { child.stdin!.write(line + '\n') } beforeAll(async () => { const home = process.env['HOME']! configPath = join(home, '.config', 'codeburn', 'config.json') await mkdir(join(home, '.config', 'codeburn'), { recursive: true }) // Give the resident process one real provider root to arm. With no // successfully armed roots, event-driven reuse correctly stays disabled. await mkdir(join(home, '.claude', 'projects'), { recursive: true }) await writeFile(configPath, JSON.stringify({ currency: { code: 'USD' } }), 'utf8') // Keep the EUR half of the config-freshness regression fully offline. const cacheDir = join(home, '.cache', 'codeburn') await mkdir(cacheDir, { recursive: true }) await writeFile(join(cacheDir, 'exchange-rate.json'), JSON.stringify({ timestamp: Date.now(), code: 'EUR', rate: 0.9, }), 'utf8') child = spawn(process.execPath, ['--import', 'tsx', join(__dirname, '..', 'src', 'cli.ts'), 'serve', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env }, }) child.stdout!.setEncoding('utf8') child.stdout!.on('data', (chunk: string) => { buffer += chunk let idx: number while ((idx = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, idx).trim() buffer = buffer.slice(idx + 1) if (!line) continue let msg: Record try { msg = JSON.parse(line) } catch { continue } if (msg['ready']) { readyResolve(); continue } if (typeof msg['progress'] === 'string' && !('ok' in msg)) { const id = msg['id'] as number const frames = progressFrames.get(id) ?? [] frames.push(msg) progressFrames.set(id, frames) continue } const waiter = waiters.get(msg['id'] as number) if (waiter) { waiters.delete(msg['id'] as number); waiter(msg) } } }) await ready }, 60_000) afterAll(() => { child?.kill('SIGKILL') }) it('answers an allowed query with the command stdout', async () => { const res = await request(1, ['status', '--format', 'menubar-json', '--period', 'today']) expect(res['ok']).toBe(true) const payload = JSON.parse(res['output'] as string) as { current: { label: string } } expect(payload.current.label).toContain('Today') }, 60_000) it('isolates option state between requests (no sticky --period)', async () => { // The whole reason serve rebuilds the program per request: commander // option state is sticky, and a leaked --period would mislabel every // later panel. const month = await request(2, ['status', '--format', 'menubar-json', '--period', 'month']) const today = await request(3, ['status', '--format', 'menubar-json', '--period', 'today']) const monthLabel = (JSON.parse(month['output'] as string) as { current: { label: string } }).current.label const todayLabel = (JSON.parse(today['output'] as string) as { current: { label: string } }).current.label expect(monthLabel).not.toBe(todayLabel) expect(todayLabel).toContain('Today') }, 60_000) it('refuses commands outside the read allowlist', async () => { const res = await request(4, ['currency', 'EUR']) expect(res['ok']).toBe(false) expect(res['refused']).toBe(true) }) it('refuses a smuggled positional on an allowed command', async () => { const res = await request(5, ['sessions', 'positional-arg']) expect(res['ok']).toBe(false) expect(res['refused']).toBe(true) }) it('refuses every optimize apply-only option without touching shell config or the action journal', async () => { const home = process.env['HOME']! const zshrc = join(home, '.zshrc') const journal = join(home, '.config', 'codeburn', 'actions', 'journal.jsonl') await writeFile(zshrc, '# user-owned\n', 'utf8') // `optimize` is the only served command whose Commander definition also // has mutation-capable options. The full request below used to execute a // shell-config action inside the resident process. const applied = await request(300, [ 'optimize', '--apply', '--yes', '--only', 'bash-output-cap', '--period', 'today', ]) expect(applied).toMatchObject({ ok: false, refused: true }) // Keep the allowlist categorical: apply-only modifiers are not useful to // a read query and must not become resident options on their own either. for (const [id, args] of [ [301, ['optimize', '--yes']], [302, ['optimize', '--dry-run']], [303, ['optimize', '--only', 'bash-output-cap']], ] as const) { expect(await request(id, [...args])).toMatchObject({ ok: false, refused: true }) } expect(await readFile(zshrc, 'utf8')).toBe('# user-owned\n') await expect(readFile(journal, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) }, 60_000) it('accepts the reviewed read-only option surface for every served command', async () => { const commands: Array<[number, string[]]> = [ [310, ['status', '--format', 'json', '--period', 'today']], [311, ['overview', '--period', 'today', '--no-color']], [312, ['models', '--format', 'json', '--period', 'today', '--no-totals']], [313, ['sessions', '--format', 'json', '--period', 'today', '--no-pager']], [314, ['compare', '--format', 'json', '--period', 'today']], [315, ['yield', '--format', 'json', '--period', 'today']], [316, ['spend', '--format', 'flow-json', '--period', 'today']], [317, ['optimize', '--format', 'json', '--period', 'today']], [318, ['audit', '--format', 'json', '--period', 'today']], ] for (const [id, args] of commands) { expect(await request(id, args)).toMatchObject({ ok: true }) } }, 60_000) it('survives a malformed request line and keeps serving', async () => { sendRaw('this is not json') const res = await request(6, ['status', '--format', 'menubar-json', '--period', 'today']) expect(res['ok']).toBe(true) }, 60_000) it('streams captured command stderr as protocol progress frames', async () => { const res = await request(7, ['status', '--provider', 'definitely-not-a-real-provider']) expect(res['ok']).toBe(false) const frames = progressFrames.get(7) ?? [] expect(frames.length).toBeGreaterThan(0) expect(frames.every(frame => Object.keys(frame).sort().join(',') === 'id,progress')).toBe(true) expect(frames.map(frame => frame['progress']).join('')).toContain('unknown provider') }, 60_000) it('discovers a newly configured Claude root on identical resident argv', async () => { const home = process.env['HOME']! const rootA = join(home, 'claude-root-a') const rootB = join(home, 'claude-root-b') const slug = '-Users-test-shared-project' const cwd = '/Users/test/shared-project' const writeClaudeSession = async (root: string, sessionId: string, marker: string): Promise => { const projectDir = join(root, 'projects', slug) await mkdir(projectDir, { recursive: true }) const lines = [ { type: 'summary', summary: marker, leafUuid: `leaf-${marker}`, sessionId, cwd, timestamp: '2026-08-12T10:00:00.000Z', }, { type: 'user', uuid: `user-${marker}`, sessionId, cwd, timestamp: '2026-08-12T10:00:01.000Z', message: { role: 'user', content: marker }, }, { type: 'assistant', uuid: `assistant-${marker}`, parentUuid: `user-${marker}`, sessionId, cwd, timestamp: '2026-08-12T10:00:02.000Z', message: { id: `msg-${marker}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'reply' }], usage: { input_tokens: 100, output_tokens: 50 }, }, }, ] await writeFile(join(projectDir, `${sessionId}.jsonl`), lines.map(line => JSON.stringify(line)).join('\n')) } await writeClaudeSession(rootA, 'resident-session-a', 'a') await writeClaudeSession(rootB, 'resident-session-b', 'b') const args = ['sessions', '--period', 'lifetime', '--provider', 'claude', '--format', 'json', '--no-pager'] await writeFile(configPath, JSON.stringify({ claudeConfigDirs: [rootA] }), 'utf8') const first = await request(200, args) expect(first['ok']).toBe(true) expect((JSON.parse(first['output'] as string) as Array<{ sessionId: string }>).map(row => row.sessionId)).toEqual([ 'resident-session-a', ]) // Same command in the same process; only config.json adds root B. await writeFile(configPath, JSON.stringify({ claudeConfigDirs: [rootA, rootB] }), 'utf8') const second = await request(201, args) expect(second['ok']).toBe(true) expect((JSON.parse(second['output'] as string) as Array<{ sessionId: string }>).map(row => row.sessionId).sort()).toEqual([ 'resident-session-a', 'resident-session-b', ]) // Keep the following currency-freshness regression self-contained. await writeFile(configPath, JSON.stringify({ currency: { code: 'USD' } }), 'utf8') }, 60_000) it('invalidates identical-argv output memo immediately when config.json changes', async () => { const args = ['status', '--format', 'menubar-json', '--period', 'week', '--no-optimize', '--no-timeline'] const usdConfig = JSON.stringify({ currency: { code: 'USD' } }) await writeFile(configPath, usdConfig, 'utf8') let previous = await request(8, args) expect(previous['ok']).toBe(true) expect((JSON.parse(previous['output'] as string) as { currency: { code: string } }).currency.code).toBe('USD') // Prove this argv is actually hitting the output memo before testing its // invalidation. The root watchers arm asynchronously at serve startup, so // allow a few requests until two byte-identical generated payloads arrive. let memoized: Record | null = null for (let id = 9; id < 110; id++) { await new Promise(resolve => setTimeout(resolve, 20)) const next = await request(id, args) if (next['output'] === previous['output']) { memoized = next break } previous = next } expect(memoized).not.toBeNull() // A byte-identical rewrite changes filesystem metadata but not effective // configuration. The memo must survive it and return the exact generated // payload, including the original volatile `generated` timestamp. await new Promise(resolve => setTimeout(resolve, 20)) await writeFile(configPath, usdConfig, 'utf8') const sameBytes = await request(110, args) expect(sameBytes['ok']).toBe(true) expect(sameBytes['output']).toBe(memoized!['output']) // `generated` is minted per render, so an unchanged stamp is the proof // that a memo hit returns the stored string instead of re-rendering. const stamp = (res: Record): string => (JSON.parse(res['output'] as string) as { generated: string }).generated expect(stamp(sameBytes)).toBe(stamp(memoized!)) // Same byte length as USD: a size-only fingerprint would miss this. await writeFile(configPath, JSON.stringify({ currency: { code: 'EUR' } }), 'utf8') const fresh = await request(111, args) expect(fresh['ok']).toBe(true) expect((JSON.parse(fresh['output'] as string) as { currency: { code: string } }).currency.code).toBe('EUR') expect(fresh['output']).not.toBe(memoized!['output']) // Removing the configured currency is the USD reset contract. The serve // process must reset its module-level currency state as well as invalidate // the output memo, otherwise a long-lived child keeps rendering EUR. await writeFile(configPath, '{}', 'utf8') const reset = await request(112, args) expect(reset['ok']).toBe(true) expect((JSON.parse(reset['output'] as string) as { currency: { code: string; rate: number } }).currency).toMatchObject({ code: 'USD', rate: 1 }) }, 60_000) it('exits on natural stdin EOF after arming a watcher for an existing Claude root', async () => { const claudeRoot = join(process.env['HOME']!, 'claude-eof-root') await mkdir(join(claudeRoot, 'projects'), { recursive: true }) const eofChild = spawn(process.execPath, ['--import', 'tsx', join(__dirname, '..', 'src', 'cli.ts'), 'serve', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, CLAUDE_CONFIG_DIR: claudeRoot }, }) let stdout = '' const becameReady = new Promise((resolve, reject) => { eofChild.once('error', reject) eofChild.stdout!.setEncoding('utf8') eofChild.stdout!.on('data', (chunk: string) => { stdout += chunk if (stdout.split('\n').some(line => { try { return (JSON.parse(line) as { ready?: boolean }).ready === true } catch { return false } })) resolve() }) eofChild.once('exit', (code, signal) => reject(new Error(`serve exited before ready: ${code ?? signal}`))) }) const exited = new Promise(resolve => eofChild.once('exit', () => resolve(true))) let naturalExit = false try { await becameReady // READY is intentionally emitted before provider probing; give the real // watcher setup time to finish so the regression exercises its handle. await new Promise(resolve => setTimeout(resolve, 500)) eofChild.stdin!.end() naturalExit = await Promise.race([ exited, new Promise(resolve => setTimeout(() => resolve(false), 2_000)), ]) } finally { if (!naturalExit) { eofChild.kill('SIGKILL') await exited } } expect(naturalExit).toBe(true) }, 10_000) })