codeburn/tests/doctor.test.ts
Resham Joshi 3dd1d1c793
feat(doctor): per-provider detection diagnostics (#685)
* feat(doctor): add per-provider detection diagnostics command

When a provider reports zero (or a number that looks wrong) there was no way
to see why: whether the tool is not installed, an env override points at the
wrong directory, the data dir is empty, or parsing failed. Users had to file
issues like "OpenCode does not work" with nothing to go on.

`codeburn doctor` closes that gap. For every provider (or one via --provider)
it shows the exact directories/dbs probed with any env override and whether the
path exists, how many session files were discovered, how many of a bounded
sample parsed cleanly, the cached file count and parser version, and a one-line
verdict: OK (n sessions), NOTHING FOUND with a concrete likely cause, or
ERRORS (n parse failures). Output is a human table by default or --json.

The collect logic is a pure function separated from rendering so tests exercise
it without a TTY. It runs fully offline and read-only (never writes caches or
config, and skips the one network provider's parse), and isolates each provider
in its own try/catch so a single thrower becomes an error row instead of
crashing the report. Providers expose their scan roots via an optional
probeRoots() so the exact paths are shown even when zero sessions are found,
reusing each provider's own path resolution rather than duplicating it.

* fix(doctor): make the inert promise actually true

Adversarial re-review reproduced two false guarantees. Cursor's parser
writes its results cache to disk before its first yield, so a doctor run
created ~/.cache/codeburn/cursor-results.json on a clean machine; the
collect pass now sets CODEBURN_SUPPRESS_CACHE_WRITES (restored after) and
the cursor writer honors it. Antigravity's parse probes for a live
language server (spawns ps and lsof, RPCs the IDE when found); doctor now
skips its parse sample the way network providers are skipped, keeping
discovery counts meaningful.

Also stop blaming CODEBURN_CACHE_DIR in NOTHING FOUND hints (it is our
cache location, not a discovery path) and correct the sample-cap comment:
the cap truncates yields, eager parsers still do whole-file work.
2026-07-16 10:38:08 -07:00

330 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { collectDoctorReport, renderDoctorTable, renderDoctorJson } from '../src/doctor.js'
import { createCodexProvider } from '../src/providers/codex.js'
import { emptyCache, type SessionCache } from '../src/session-cache.js'
import type { Provider, ProbeRoot, SessionSource } from '../src/providers/types.js'
// ── Helpers ──────────────────────────────────────────────────────────────
// A valid single-line Codex rollout so real discovery + probeRoots + parse run
// end to end against a fixture directory.
function sessionMeta(): string {
return JSON.stringify({
type: 'session_meta',
timestamp: '2026-04-14T10:00:00Z',
payload: {
cwd: '/Users/test/proj',
originator: 'codex-cli',
session_id: 'sess-001',
model: 'gpt-5.3-codex',
},
})
}
function tokenCount(): string {
return JSON.stringify({
type: 'event_msg',
timestamp: '2026-04-14T10:01:00Z',
payload: {
type: 'token_count',
info: {
total_token_usage: {
input_tokens: 100,
cached_input_tokens: 0,
output_tokens: 50,
reasoning_output_tokens: 0,
total_tokens: 150,
},
},
},
})
}
async function writeCodexSession(codexDir: string): Promise<void> {
const dayDir = join(codexDir, 'sessions', '2026', '04', '14')
await mkdir(dayDir, { recursive: true })
await writeFile(join(dayDir, 'rollout-sess-001.jsonl'), `${sessionMeta()}\n${tokenCount()}\n`)
}
// Fully controllable synthetic provider for the edge cases (network, throwing,
// parse failure) that on-disk fixtures cannot force deterministically.
function fakeProvider(over: Partial<Provider> & { name: string }): Provider {
return {
displayName: over.name,
modelDisplayName: (m: string) => m,
toolDisplayName: (t: string) => t,
discoverSessions: async () => [],
createSessionParser: () => ({ async *parse() {} }),
...over,
}
}
function only(report: Awaited<ReturnType<typeof collectDoctorReport>>, name: string) {
const r = report.providers.find(p => p.provider === name)
if (!r) throw new Error(`no report row for ${name}`)
return r
}
let tmpDir: string
beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'doctor-test-')) })
afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) })
// ── Real-provider fixture-dir cases: found / empty / missing ───────────────
describe('collectDoctorReport - codex fixture dirs', () => {
it('found: a real session file yields an OK verdict and a parsed sample', async () => {
await writeCodexSession(tmpDir)
const provider = createCodexProvider(tmpDir)
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const r = only(report, 'codex')
expect(r.status).toBe('ok')
expect(r.candidatesFound).toBeGreaterThanOrEqual(1)
expect(r.parsedOk).toBeGreaterThanOrEqual(1)
expect(r.parseFailed).toBe(0)
expect(r.verdict).toMatch(/^OK \(/)
const sessions = r.probePaths.find(p => p.label === 'sessions')
expect(sessions?.exists).toBe(true)
})
it('empty: sessions dir exists but holds nothing -> NOTHING FOUND (no sessions)', async () => {
await mkdir(join(tmpDir, 'sessions'), { recursive: true })
const provider = createCodexProvider(tmpDir)
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const r = only(report, 'codex')
expect(r.status).toBe('empty')
expect(r.candidatesFound).toBe(0)
expect(r.verdict).toContain('holds no sessions')
expect(r.probePaths.find(p => p.label === 'sessions')?.exists).toBe(true)
})
it('missing: no sessions dir -> NOTHING FOUND names the missing path', async () => {
// tmpDir has no `sessions` subdir at all.
const provider = createCodexProvider(tmpDir)
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const r = only(report, 'codex')
expect(r.status).toBe('empty')
expect(r.candidatesFound).toBe(0)
// Mutation guard: if the exists-check is broken to always return true, this
// flips to false and the verdict drops "does not exist".
expect(r.probePaths.find(p => p.label === 'sessions')?.exists).toBe(false)
expect(r.verdict).toContain('does not exist')
expect(r.verdict).toContain(join(tmpDir, 'sessions'))
})
})
// ── Override case ──────────────────────────────────────────────────────────
describe('collectDoctorReport - env override', () => {
it('names a set override pointing at a missing dir', async () => {
const prev = process.env['CODEX_HOME']
const bogus = join(tmpDir, 'does-not-exist')
process.env['CODEX_HOME'] = bogus
try {
// Construct after setting env so the provider resolves CODEX_HOME.
const provider = createCodexProvider()
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const r = only(report, 'codex')
expect(r.envOverrides).toEqual([{ name: 'CODEX_HOME', value: bogus }])
expect(r.status).toBe('empty')
expect(r.verdict).toContain('override CODEX_HOME set')
expect(r.verdict).toContain('does not exist')
} finally {
if (prev === undefined) delete process.env['CODEX_HOME']
else process.env['CODEX_HOME'] = prev
}
})
})
// ── Synthetic edge cases ───────────────────────────────────────────────────
describe('collectDoctorReport - isolation and edge cases', () => {
it('a provider that throws in discovery becomes an ERROR row, others survive', async () => {
const boom = fakeProvider({
name: 'boom',
discoverSessions: async () => { throw new Error('disk on fire') },
})
const good = fakeProvider({
name: 'good',
discoverSessions: async () => [{ path: '/x/a.json', project: 'p', provider: 'good' }],
})
const report = await collectDoctorReport('all', { providers: [boom, good], cache: emptyCache() })
const b = only(report, 'boom')
expect(b.status).toBe('error')
expect(b.error).toContain('disk on fire')
expect(b.verdict).toMatch(/^ERROR \(/)
expect(only(report, 'good').status).toBe('ok')
})
it('a parser that throws is counted and downgrades the verdict to ERRORS', async () => {
const src: SessionSource = { path: '/x/a.json', project: 'p', provider: 'flaky' }
const flaky = fakeProvider({
name: 'flaky',
discoverSessions: async () => [src],
createSessionParser: () => ({
// eslint-disable-next-line require-yield
async *parse() { throw new Error('bad json') },
}),
})
const r = only(await collectDoctorReport('all', { providers: [flaky], cache: emptyCache() }), 'flaky')
expect(r.status).toBe('errors')
expect(r.parseFailed).toBe(1)
expect(r.parsedOk).toBe(0)
expect(r.verdict).toMatch(/^ERRORS \(/)
})
it('network providers are never parsed offline', async () => {
let parserCreated = false
const net = fakeProvider({
name: 'net',
network: true,
discoverSessions: async () => [{ path: 'net:report', project: 'p', provider: 'net' }],
createSessionParser: () => { parserCreated = true; return { async *parse() {} } },
})
const r = only(await collectDoctorReport('all', { providers: [net], cache: emptyCache() }), 'net')
expect(parserCreated).toBe(false)
expect(r.status).toBe('network')
expect(r.sampled).toBe(0)
expect(r.verdict).toContain('NETWORK')
})
it('bounds the parse sample and flags it', async () => {
const sources: SessionSource[] = Array.from({ length: 5 }, (_, i) => ({ path: `/x/${i}.json`, project: 'p', provider: 'many' }))
const many = fakeProvider({
name: 'many',
discoverSessions: async () => sources,
createSessionParser: () => ({ async *parse() { yield undefined as never } }),
})
const r = only(await collectDoctorReport('all', { providers: [many], cache: emptyCache(), sampleLimit: 2 }), 'many')
expect(r.candidatesFound).toBe(5)
expect(r.sampled).toBe(2)
expect(r.bounded).toBe(true)
expect(r.status).toBe('ok')
})
it('reports cache state from the injected snapshot', async () => {
const cache: SessionCache = emptyCache()
cache.providers['cached'] = {
envFingerprint: 'x',
files: {
'/a.jsonl': { fingerprint: { dev: 1, ino: 1, mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [] },
'/b.jsonl': { fingerprint: { dev: 1, ino: 2, mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [], failed: true },
},
}
const provider = fakeProvider({ name: 'cached', discoverSessions: async () => [] })
const r = only(await collectDoctorReport('all', { providers: [provider], cache }), 'cached')
expect(r.cachedFiles).toBe(2)
expect(r.cachedFailed).toBe(1)
})
it('filters to a single provider by name', async () => {
const a = fakeProvider({ name: 'a' })
const b = fakeProvider({ name: 'b' })
const report = await collectDoctorReport('b', { providers: [a, b], cache: emptyCache() })
expect(report.providers.map(p => p.provider)).toEqual(['b'])
})
})
// ── Rendering ──────────────────────────────────────────────────────────────
describe('doctor rendering', () => {
it('renders a plain-text table naming the override and missing path', async () => {
const provider = fakeProvider({
name: 'claude',
displayName: 'Claude',
probeRoots: async (): Promise<ProbeRoot[]> => [{ path: '/nonexistent/projects', label: 'projects' }],
discoverSessions: async () => [],
})
const prev = process.env['CLAUDE_CONFIG_DIR']
process.env['CLAUDE_CONFIG_DIR'] = '/nonexistent'
try {
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const out = renderDoctorTable(report, { color: false })
expect(out).toContain('CodeBurn doctor')
expect(out).toContain('NOTHING FOUND')
expect(out).toContain('CLAUDE_CONFIG_DIR=/nonexistent')
expect(out).toContain('/nonexistent/projects')
expect(out).toContain('missing')
// no-color mode emits no ANSI escapes
// eslint-disable-next-line no-control-regex
expect(out).not.toMatch(/\[/)
} finally {
if (prev === undefined) delete process.env['CLAUDE_CONFIG_DIR']
else process.env['CLAUDE_CONFIG_DIR'] = prev
}
})
it('emits valid, round-trippable JSON', async () => {
const provider = fakeProvider({ name: 'a', discoverSessions: async () => [] })
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
const parsed = JSON.parse(renderDoctorJson(report))
expect(parsed.providers[0].provider).toBe('a')
expect(typeof parsed.generatedAt).toBe('string')
})
})
// ── Re-review hardening (#685): inert-diagnostic guarantees ────────────────
describe('doctor is inert', () => {
it('sets the cache-write suppression flag while collecting and restores it after', async () => {
let flagDuringParse: string | undefined
const spy = fakeProvider({
name: 'spy',
discoverSessions: async () => [{ path: '/tmp/x', project: 'p', provider: 'spy' }],
createSessionParser: () => ({
async *parse() {
flagDuringParse = process.env['CODEBURN_SUPPRESS_CACHE_WRITES']
},
}),
})
delete process.env['CODEBURN_SUPPRESS_CACHE_WRITES']
await collectDoctorReport('spy', { providers: [spy], cache: emptyCache() })
expect(flagDuringParse).toBe('1')
expect(process.env['CODEBURN_SUPPRESS_CACHE_WRITES']).toBeUndefined()
})
it('never sample-parses a provider whose parse spawns processes (antigravity)', async () => {
let parsed = false
const ag = fakeProvider({
name: 'antigravity',
discoverSessions: async () => [{ path: '/tmp/cascade-x.pb', project: 'p', provider: 'antigravity' }],
createSessionParser: () => ({
async *parse() {
parsed = true
},
}),
})
const report = await collectDoctorReport('antigravity', { providers: [ag], cache: emptyCache() })
expect(parsed).toBe(false)
const row = only(report, 'antigravity')
expect(row.status).toBe('ok')
expect(row.verdict).toContain('parse sample skipped')
expect(row.candidatesFound).toBe(1)
})
it('does not blame CODEBURN_CACHE_DIR for an empty provider', async () => {
const prev = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = '/tmp/some-cache'
try {
const empty = fakeProvider({ name: 'antigravity' })
const report = await collectDoctorReport('antigravity', { providers: [empty], cache: emptyCache() })
const row = only(report, 'antigravity')
expect(row.verdict).not.toContain('CODEBURN_CACHE_DIR')
} finally {
if (prev === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = prev
}
})
})