mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
A memo hit must not re-render. The generated stamp is minted per render, so asserting it is unchanged across the hit states that contract directly instead of leaving it implied by byte equality.
350 lines
16 KiB
TypeScript
350 lines
16 KiB
TypeScript
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<number, (msg: Record<string, unknown>) => void>()
|
|
const progressFrames = new Map<number, Array<Record<string, unknown>>>()
|
|
let configPath = ''
|
|
let readyResolve: () => void
|
|
const ready = new Promise<void>(resolve => { readyResolve = resolve })
|
|
|
|
function request(id: number, args: string[]): Promise<Record<string, unknown>> {
|
|
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<string, unknown>
|
|
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<void> => {
|
|
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<string, unknown> | 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, unknown>): 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<void>((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<boolean>(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<false>(resolve => setTimeout(() => resolve(false), 2_000)),
|
|
])
|
|
} finally {
|
|
if (!naturalExit) {
|
|
eofChild.kill('SIGKILL')
|
|
await exited
|
|
}
|
|
}
|
|
|
|
expect(naturalExit).toBe(true)
|
|
}, 10_000)
|
|
})
|