mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-28 01:53:17 +00:00
* fix(cache-lock): coalesce heartbeat ticks so the fence never waits behind a backlog Each interval tick queued a full heartbeat in the owner serializer, so an interval firing faster than a tick completes grew the queue without bound and verifyStillOwner waited behind all of it. The 1ms-heartbeat fence test hit its 30s timeout on a loaded runner and retried up to 10 times, which is the silent cache-lock suite hang in CI. * test(menubar): hold off the helper's SIGTERM until the stand-in app records its status The relaunch helper signals the app as soon as the CLI child is reaped, which could land between the stand-in's fopen and fclose and leave an empty status file. Also wait for the recorder's full line instead of a fixed sleep. * test: pin day-boundary tests so they pass at any time of day CLI children that query "today" now run in a fixed-offset zone where it is about noon on the UTC date (tests/fixtures/noon-tz.ts), so fixtures a little in the past are always today; the old clamp to UTC midnight put them in the future for the first minutes of the day and zeroed the counts. The month-scoped budget and project-filter fixtures get the same treatment (the latter also failed on days 1-5 of every month), the cursor lookback case derives its start from now instead of a date that ages out on 1 Oct, and the renderer tests that format a stamp from a few hours back pin the clock to mid-day. The session drawer date is formatted in the local zone, so its expectation is too. * test: derive dated fixtures from the clock so rolling windows never age them out The six-month --period all window and the sync ledger's six-month prune dropped fixtures fixed at April-July 2026 from 1 Nov 2026 on. CLI fixtures now sit 30 days back (utcDaysAgo), the ledger and push tests pin Date to July 2026, the --to-only range uses yesterday, and the date-picker tests pin mid-month so two days back is on the calendar they open (they failed on the 1st and 2nd of every month). The noon-tz helper moves to tests/fixtures/clock.ts. * test(desktop): give spawned stand-ins room to boot before the silence window The watchdog and graceful-kill tests used 1-1.5s silence windows, and the window before a child's first byte includes node's boot. On a loaded machine the child was signalled before it installed its handler or wrote its byte. The windows are now 3s and the last-byte test measures from the byte the child stamps. The killAll and fallback-scheduler tests wait on their condition with a 15s deadline instead of a 2-3s one.
740 lines
35 KiB
TypeScript
740 lines
35 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
import { spawn, type ChildProcess } from 'child_process'
|
|
import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { classifyRootReuse, createOutputMemoEntry, fileDaySpan, outputMemoKey, servedDayRange } from '../src/serve.js'
|
|
import { noonTz } from './fixtures/clock.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')
|
|
})
|
|
|
|
describe('day-scoped invalidation', () => {
|
|
const day = (d: string): number => new Date(`${d}T12:00:00`).getTime()
|
|
const startOfDay = (ms: number): number => {
|
|
const x = new Date(ms)
|
|
return new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
|
|
}
|
|
const spanOf = (from: string, to: string) => fileDaySpan({ birthtimeMs: day(from), mtimeMs: day(to) }, startOfDay)
|
|
const august = { startMs: day('2026-08-20'), endMs: day('2026-08-20') + 3600_000 }
|
|
const dirty = { startedAt: 50, lastEventAt: 100, healthy: true }
|
|
|
|
it('covers every day between the creation of a file and its last write, plus a day of slack', () => {
|
|
const span = spanOf('2026-09-15', '2026-09-16')
|
|
expect(span.startMs).toBe(startOfDay(day('2026-09-14')))
|
|
expect(span.endMs).toBe(startOfDay(day('2026-09-17')) - 1)
|
|
})
|
|
|
|
it('keeps a finalized past range clean when only files from today changed', () => {
|
|
const state = { ...dirty, changedSince: () => ['/roots/today.jsonl'] }
|
|
expect(classifyRootReuse(100, state, august, () => spanOf('2026-09-16', '2026-09-16'))).toBe('clean')
|
|
})
|
|
|
|
it('dirties a range a changed file could have written into', () => {
|
|
const state = { ...dirty, changedSince: () => ['/roots/old.jsonl'] }
|
|
// Born before the queried day and still being appended: its own days reach
|
|
// into the range, so the range is not reusable.
|
|
expect(classifyRootReuse(100, state, august, () => spanOf('2026-08-19', '2026-09-16'))).toBe('dirty')
|
|
})
|
|
|
|
it('refuses to scope an event it cannot place', () => {
|
|
const unknownSpan = { ...dirty, changedSince: () => ['/roots/gone.jsonl'] }
|
|
expect(classifyRootReuse(100, unknownSpan, august, () => null)).toBe('dirty')
|
|
const unnamed = { ...dirty, changedSince: () => null }
|
|
expect(classifyRootReuse(100, unnamed, august, () => spanOf('2026-09-16', '2026-09-16'))).toBe('dirty')
|
|
// No range to scope against is the old, whole-corpus answer.
|
|
expect(classifyRootReuse(100, { ...dirty, changedSince: () => [] })).toBe('dirty')
|
|
})
|
|
|
|
it('still reports unknown coverage rather than clean', () => {
|
|
const state = { startedAt: 150, lastEventAt: 100, healthy: true, changedSince: () => ['/roots/today.jsonl'] }
|
|
expect(classifyRootReuse(100, state, august, () => spanOf('2026-09-16', '2026-09-16'))).toBe('unknown')
|
|
})
|
|
})
|
|
|
|
// 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('stamps every answer with the generation it was derived in', async () => {
|
|
const first = await request(40, ['status', '--format', 'menubar-json', '--period', 'today'])
|
|
const second = await request(41, ['status', '--format', 'menubar-json', '--period', 'week'])
|
|
const firstGen = first['generation'] as { n: number; at: string }
|
|
const secondGen = second['generation'] as { n: number; at: string }
|
|
expect(firstGen.n).toBeGreaterThan(0)
|
|
// A distinct derivation advances the counter, and says when it happened.
|
|
expect(secondGen.n).toBeGreaterThan(firstGen.n)
|
|
expect(Number.isNaN(Date.parse(secondGen.at))).toBe(false)
|
|
expect(Date.parse(secondGen.at)).toBeGreaterThanOrEqual(Date.parse(firstGen.at))
|
|
|
|
// A repeat of the first query is either re-derived (a new counter) or
|
|
// served from the memo, in which case it carries the SAME stamp it was
|
|
// derived under rather than the moment it was handed over.
|
|
const repeat = await request(42, ['status', '--format', 'menubar-json', '--period', 'today'])
|
|
const repeatGen = repeat['generation'] as { n: number; at: string }
|
|
if (repeat['output'] === first['output']) {
|
|
expect([firstGen.n, secondGen.n + 1]).toContain(repeatGen.n)
|
|
}
|
|
expect(repeatGen.n).toBeGreaterThan(0)
|
|
}, 60_000)
|
|
|
|
// The desktop app buckets these into its consent-gated app_close event: serve
|
|
// is a plain CLI child, so Electron's own metrics cannot see this cost.
|
|
it('reports its own CPU seconds and peak RSS on an answer', async () => {
|
|
const res = await request(43, ['status', '--format', 'menubar-json', '--period', 'today'])
|
|
const usage = res['usage'] as { cpuSec: number; rssMb: number }
|
|
expect(usage.cpuSec).toBeGreaterThan(0)
|
|
expect(usage.rssMb).toBeGreaterThan(0)
|
|
}, 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']],
|
|
[319, ['report', '--format', 'json', '--period', 'today']],
|
|
]
|
|
for (const [id, args] of commands) {
|
|
expect(await request(id, args)).toMatchObject({ ok: true })
|
|
}
|
|
}, 60_000)
|
|
|
|
// SERVE_OPTIONS mirrors the Commander definitions: a command that declares
|
|
// --project/--exclude but is missing them here falls back to a cold spawn.
|
|
it('routes --project/--exclude for every command that declares them', async () => {
|
|
const commands: Array<[number, string[]]> = [
|
|
[330, ['status', '--format', 'json', '--period', 'today', '--project', '/nope']],
|
|
[331, ['overview', '--period', 'today', '--no-color', '--exclude', '/nope']],
|
|
[332, ['models', '--format', 'json', '--period', 'today', '--project', '/nope']],
|
|
[333, ['sessions', '--format', 'json', '--period', 'today', '--exclude', '/nope']],
|
|
[334, ['compare', '--format', 'json', '--period', 'today', '--project', '/nope']],
|
|
[335, ['yield', '--format', 'json', '--period', 'today', '--exclude', '/nope']],
|
|
[336, ['spend', '--format', 'flow-json', '--period', 'today', '--project', '/nope']],
|
|
[337, ['optimize', '--format', 'json', '--period', 'today', '--exclude', '/nope']],
|
|
[338, ['audit', '--format', 'json', '--period', 'today', '--project', '/nope']],
|
|
[339, ['report', '--format', 'json', '--period', 'today', '--exclude', '/nope']],
|
|
]
|
|
for (const [id, args] of commands) {
|
|
expect(await request(id, args)).toMatchObject({ ok: true })
|
|
}
|
|
}, 60_000)
|
|
|
|
// #1451: --route/--billing are call-level slicers on models/sessions/audit,
|
|
// the same shape as --project/--exclude, and must be servable the same way.
|
|
it('routes --route/--billing for every command that declares them', async () => {
|
|
const commands: Array<[number, string[]]> = [
|
|
[350, ['models', '--format', 'json', '--period', 'today', '--route', 'bedrock']],
|
|
[351, ['sessions', '--format', 'json', '--period', 'today', '--billing', 'metered']],
|
|
[352, ['audit', '--format', 'json', '--period', 'today', '--route', 'direct', '--billing', 'subscription']],
|
|
]
|
|
for (const [id, args] of commands) {
|
|
expect(await request(id, args)).toMatchObject({ ok: true })
|
|
}
|
|
}, 60_000)
|
|
|
|
// `report` is the interactive dashboard on every format but json, and a TUI
|
|
// inside a child whose stdout is the wire would write frames nobody can read.
|
|
// Only its JSON form is servable; the rest is refused and falls back to a
|
|
// one-shot spawn, which is where an interactive dashboard belongs.
|
|
it('serves the JSON report but refuses its interactive form', async () => {
|
|
expect(await request(340, ['report', '--format', 'json', '--period', 'today'])).toMatchObject({ ok: true })
|
|
expect(await request(341, ['report', '--period', 'today'])).toMatchObject({ ok: false, refused: true })
|
|
expect(await request(342, ['report', '--format', 'tui', '--period', 'today'])).toMatchObject({ ok: false, refused: true })
|
|
// --refresh only paces the dashboard, so it is not part of the served surface.
|
|
expect(await request(343, ['report', '--format', 'json', '--period', 'today', '--refresh', '60'])).toMatchObject({ ok: false, refused: 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)
|
|
|
|
it('answers an in-flight request in full when stdin closes on the same tick', async () => {
|
|
// The transport closing does not cancel work already accepted. Returning
|
|
// before the queue drains loses the response frame outright, and because
|
|
// runCaptured() monkeypatches process.exit into a thrown ExitSignal it also
|
|
// turns the clean exit into a failure.
|
|
const raceChild = spawn(process.execPath, ['--import', 'tsx', join(__dirname, '..', 'src', 'cli.ts'), 'serve', '--stdio'], {
|
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
env: { ...process.env },
|
|
})
|
|
let stdout = ''
|
|
const lines = (): Array<Record<string, unknown>> => stdout.split('\n')
|
|
.map(line => { try { return JSON.parse(line) as Record<string, unknown> } catch { return null } })
|
|
.filter((v): v is Record<string, unknown> => v !== null)
|
|
|
|
const becameReady = new Promise<void>((resolve, reject) => {
|
|
raceChild.once('error', reject)
|
|
raceChild.stdout!.setEncoding('utf8')
|
|
raceChild.stdout!.on('data', (chunk: string) => {
|
|
stdout += chunk
|
|
if (lines().some(msg => msg['ready'] === true)) resolve()
|
|
})
|
|
raceChild.once('exit', (code, signal) => reject(new Error(`serve exited before ready: ${code ?? signal}`)))
|
|
})
|
|
const exited = new Promise<number | null>(resolve => raceChild.once('exit', code => resolve(code)))
|
|
|
|
await becameReady
|
|
// Request and EOF in the same tick: the request is accepted, then the
|
|
// transport is gone before it can possibly have finished.
|
|
raceChild.stdin!.write(JSON.stringify({ id: 77, args: ['status', '--format', 'json'] }) + '\n')
|
|
raceChild.stdin!.end()
|
|
|
|
const code = await Promise.race([
|
|
exited,
|
|
new Promise<'hung'>(resolve => setTimeout(() => resolve('hung'), 15_000)),
|
|
])
|
|
if (code === 'hung') { raceChild.kill('SIGKILL'); await exited }
|
|
|
|
expect(code).toBe(0)
|
|
const answer = lines().find(msg => msg['id'] === 77)
|
|
expect(answer).toBeDefined()
|
|
expect(answer!['ok']).toBe(true)
|
|
expect(typeof answer!['output']).toBe('string')
|
|
expect(() => JSON.parse(answer!['output'] as string)).not.toThrow()
|
|
}, 25_000)
|
|
|
|
it('gives up on an async-wedged request at the drain bound instead of lingering', async () => {
|
|
// The drain must not become the orphan it was added to prevent. A request
|
|
// that never settles releases the child at the bound (shortened here from
|
|
// its 45s default, which no legitimate request comes near).
|
|
const drainMs = 1_500
|
|
const wedgeChild = spawn(process.execPath, ['--import', 'tsx', join(__dirname, 'fixtures', 'serve-wedged-request.ts')], {
|
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
env: { ...process.env, CODEBURN_SERVE_DRAIN_MS: String(drainMs) },
|
|
})
|
|
let stdout = ''
|
|
const becameReady = new Promise<void>((resolve, reject) => {
|
|
wedgeChild.once('error', reject)
|
|
wedgeChild.stdout!.setEncoding('utf8')
|
|
wedgeChild.stdout!.on('data', (chunk: string) => {
|
|
stdout += chunk
|
|
if (stdout.includes('"ready"')) resolve()
|
|
})
|
|
wedgeChild.once('exit', () => reject(new Error('serve exited before ready')))
|
|
})
|
|
const exited = new Promise<number | null>(resolve => wedgeChild.once('exit', code => resolve(code)))
|
|
|
|
await becameReady
|
|
wedgeChild.stdin!.write(JSON.stringify({ id: 91, args: ['status', '--format', 'json'] }) + '\n')
|
|
wedgeChild.stdin!.end()
|
|
|
|
const began = Date.now()
|
|
const outcome = await Promise.race([
|
|
exited.then(() => 'exited' as const),
|
|
new Promise<'hung'>(resolve => setTimeout(() => resolve('hung'), drainMs + 12_000)),
|
|
])
|
|
const elapsed = Date.now() - began
|
|
if (outcome === 'hung') { wedgeChild.kill('SIGKILL'); await exited }
|
|
|
|
expect(outcome).toBe('exited')
|
|
// It waited for the request (not an instant return) but did not wait forever.
|
|
expect(elapsed).toBeGreaterThanOrEqual(drainMs - 250)
|
|
}, 30_000)
|
|
})
|
|
|
|
describe('output memo key', () => {
|
|
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--no-timeline']
|
|
|
|
it('separates the same query asked on either side of local midnight', () => {
|
|
const before = outputMemoKey(args, new Date(2026, 8, 16, 23, 59))
|
|
const after = outputMemoKey(args, new Date(2026, 8, 17, 0, 1))
|
|
expect(before).not.toBe(after)
|
|
})
|
|
|
|
it('is stable for the same query within a day', () => {
|
|
expect(outputMemoKey(args, new Date(2026, 8, 16, 9, 0)))
|
|
.toBe(outputMemoKey(args, new Date(2026, 8, 16, 17, 30)))
|
|
})
|
|
|
|
it('separates queries whose resolved day range differs', () => {
|
|
const now = new Date(2026, 8, 16, 12, 0)
|
|
const day = ['report', '--format', 'json', '--day', '2026-08-20']
|
|
const otherDay = ['report', '--format', 'json', '--day', '2026-08-21']
|
|
expect(outputMemoKey(day, now)).not.toBe(outputMemoKey(otherDay, now))
|
|
})
|
|
})
|
|
|
|
describe('servedDayRange', () => {
|
|
const day = (d: Date): string => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
|
|
it('reads an explicit day', () => {
|
|
expect(servedDayRange(['report', '--format', 'json', '--day', '2026-08-20']))
|
|
.toEqual({ from: '2026-08-20', to: '2026-08-20' })
|
|
})
|
|
|
|
it('reads an explicit from/to window', () => {
|
|
expect(servedDayRange(['status', '--format', 'menubar-json', '--from', '2026-08-01', '--to', '2026-08-31']))
|
|
.toEqual({ from: '2026-08-01', to: '2026-08-31' })
|
|
})
|
|
|
|
it('resolves a named period against today', () => {
|
|
const today = day(new Date())
|
|
expect(servedDayRange(['status', '--format', 'menubar-json', '--period', 'today']))
|
|
.toEqual({ from: today, to: today })
|
|
const week = servedDayRange(['status', '--format', 'menubar-json', '--period', 'week'])
|
|
expect(week?.to).toBe(today)
|
|
expect(week!.from < today).toBe(true)
|
|
})
|
|
|
|
it('accepts the short period flag and the inline form', () => {
|
|
expect(servedDayRange(['models', '--format', 'json', '-p', 'today']))
|
|
.toEqual(servedDayRange(['models', '--format', 'json', '--period', 'today']))
|
|
expect(servedDayRange(['models', '--format', 'json', '--period=today']))
|
|
.toEqual(servedDayRange(['models', '--format', 'json', '--period', 'today']))
|
|
})
|
|
|
|
it('says nothing rather than guessing a default or a bad value', () => {
|
|
// The command's own default period lives in main.ts; guessing it here would
|
|
// stamp a range the answer may not have used.
|
|
expect(servedDayRange(['status', '--format', 'menubar-json'])).toBeNull()
|
|
expect(servedDayRange(['status', '--format', 'menubar-json', '--period', 'fortnight'])).toBeNull()
|
|
expect(servedDayRange(['report', '--format', 'json', '--day', 'not-a-day'])).toBeNull()
|
|
})
|
|
})
|
|
|
|
// Regression: the desktop polls `status --format menubar-json --no-optimize`,
|
|
// and `--no-optimize` used to route the resident child through the on-disk
|
|
// status snapshot. A poll landing inside that snapshot's settle window is
|
|
// answered with the deliberately deferred PRE-change payload, which serve then
|
|
// memoized; with the roots quiet again the memo stayed valid and replayed the
|
|
// stale payload until the next write or the 5-minute memo cap. Measured: the
|
|
// menubar stuck on the old cost for 40s+.
|
|
describe('codeburn serve --stdio never defers a menubar poll', () => {
|
|
let child: ChildProcess
|
|
let home = ''
|
|
let sessionFile = ''
|
|
const waiters = new Map<number, (msg: Record<string, unknown>) => void>()
|
|
let readyResolve: () => void
|
|
const ready = new Promise<void>(resolve => { readyResolve = resolve })
|
|
|
|
const calls = (res: Record<string, unknown>): number =>
|
|
(JSON.parse(res['output'] as string) as { current: { calls: number } }).current.calls
|
|
const cost = (res: Record<string, unknown>): number =>
|
|
(JSON.parse(res['output'] as string) as { current: { cost: number } }).current.cost
|
|
|
|
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')
|
|
})
|
|
}
|
|
|
|
// The corpus fixture is shaped like the ones in cli-status-menubar.test.ts:
|
|
// two hours back, served from a zone where "today" has room for it.
|
|
const base = new Date(Date.now() - 2 * 3600_000)
|
|
const ts = (offset: number): string => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
|
|
const pricedCall = (n: number, offset: number): string => [
|
|
JSON.stringify({ type: 'user', sessionId: 's1', timestamp: ts(offset), message: { role: 'user', content: 'go' } }),
|
|
JSON.stringify({
|
|
type: 'assistant', sessionId: 's1', timestamp: ts(offset + 60_000),
|
|
message: {
|
|
id: `msg-${n}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
|
|
content: [{ type: 'text', text: 'done' }],
|
|
usage: { input_tokens: 500, output_tokens: 50 },
|
|
},
|
|
}),
|
|
].join('\n')
|
|
|
|
beforeAll(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'codeburn-serve-freshness-'))
|
|
const projectDir = join(home, '.claude', 'projects', 'myapp')
|
|
await mkdir(projectDir, { recursive: true })
|
|
await mkdir(join(home, '.config', 'codeburn'), { recursive: true })
|
|
await writeFile(join(home, '.config', 'codeburn', 'config.json'), JSON.stringify({ currency: { code: 'USD' } }), 'utf8')
|
|
sessionFile = join(projectDir, 'session.jsonl')
|
|
await writeFile(sessionFile, pricedCall(1, 0) + '\n', 'utf8')
|
|
|
|
child = spawn(process.execPath, ['--import', 'tsx', join(__dirname, '..', 'src', 'cli.ts'), 'serve', '--stdio'], {
|
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
env: {
|
|
...process.env,
|
|
HOME: home, USERPROFILE: home,
|
|
CLAUDE_CONFIG_DIR: join(home, '.claude'),
|
|
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
|
|
// Force the snapshot settle window wide open. The real one is 2s, and
|
|
// racing a 2s wall clock from a CI runner is how this test would rot;
|
|
// a window this large makes "the deferral must not happen in serve"
|
|
// deterministic instead of timing-dependent.
|
|
CODEBURN_STATUS_SNAPSHOT_SETTLE_MS: '600000',
|
|
TZ: noonTz(),
|
|
},
|
|
})
|
|
let buffer = ''
|
|
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)) continue
|
|
const waiter = waiters.get(msg['id'] as number)
|
|
if (waiter) { waiters.delete(msg['id'] as number); waiter(msg) }
|
|
}
|
|
})
|
|
await ready
|
|
}, 120_000)
|
|
|
|
afterAll(async () => {
|
|
child?.kill('SIGKILL')
|
|
if (home) await rm(home, { recursive: true, force: true })
|
|
})
|
|
|
|
it('reflects a single appended call on the poll after it, and keeps reflecting it', async () => {
|
|
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--no-timeline', '--no-optimize']
|
|
|
|
const first = await request(500, args)
|
|
expect(first['ok'], JSON.stringify(first)).toBe(true)
|
|
expect(calls(first)).toBe(1)
|
|
const firstCost = cost(first)
|
|
|
|
// One append, then nothing else touches the corpus for the rest of the
|
|
// test - exactly the protocol that pinned the menubar.
|
|
await appendFile(sessionFile, pricedCall(2, 120_000) + '\n', 'utf8')
|
|
// Let the root watcher observe the write before polling, so the poll below
|
|
// is the one that has to decide between deferring and answering. Generous
|
|
// rather than tight: nothing here races the settle window.
|
|
await new Promise(resolve => setTimeout(resolve, 3000))
|
|
|
|
// Inside the settle window: a one-shot CLI may debounce here, the resident
|
|
// process must not - it has the parse state and cannot be re-asked.
|
|
const inWindow = await request(501, args)
|
|
expect(inWindow['ok'], JSON.stringify(inWindow)).toBe(true)
|
|
expect(calls(inWindow)).toBe(2)
|
|
expect(cost(inWindow)).toBeGreaterThan(firstCost)
|
|
|
|
// The actual regression: with no further filesystem event the output memo
|
|
// stays valid, so whatever the previous poll answered is replayed. A
|
|
// deferred answer here is stale for the whole memo cap.
|
|
const memoed = await request(502, args)
|
|
expect(memoed['ok'], JSON.stringify(memoed)).toBe(true)
|
|
expect(calls(memoed)).toBe(2)
|
|
expect(cost(memoed)).toBeGreaterThan(firstCost)
|
|
}, 120_000)
|
|
})
|