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 { createOpenCodeProvider } from '../src/providers/opencode.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 { 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 & { 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>, 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 } }) it('names a deliberate XDG_DATA_HOME override pointing at a missing dir, blaming the override not the install (opencode)', async () => { const prev = process.env['XDG_DATA_HOME'] const bogus = join(tmpDir, 'xdg-missing') process.env['XDG_DATA_HOME'] = bogus try { // Construct after setting env so the provider resolves XDG_DATA_HOME // (src/providers/opencode.ts:38 reads it to resolve the data dir). const provider = createOpenCodeProvider() const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() }) const r = only(report, 'opencode') expect(r.envOverrides).toContainEqual({ name: 'XDG_DATA_HOME', value: bogus }) expect(r.status).toBe('empty') // Regression (Ruling 3 of lane 04): with XDG_DATA_HOME treated as an // ambient OS var, doctor skipped it and the verdict blamed the install // ("tool likely not installed") instead of the override the user set. expect(r.verdict).toContain('override XDG_DATA_HOME set') expect(r.verdict).toContain('does not exist') } finally { if (prev === undefined) delete process.env['XDG_DATA_HOME'] else process.env['XDG_DATA_HOME'] = prev } }) // Windows sets APPDATA and LOCALAPPDATA for every process, so neither // carries user intent: both are fingerprinted (a change moves the discovery // root) but must never be named as a deliberate override (Ruling 3 of lane // 04). Table-driven over both so removing either from AMBIENT_ENV_VARS // fails a test instead of leaking it into the overrides list. for (const varName of ['APPDATA', 'LOCALAPPDATA']) { it(`does not name ${varName} as an override for a provider that declares it`, async () => { const prev = process.env[varName] process.env[varName] = join(tmpDir, varName.toLowerCase()) try { const provider = fakeProvider({ name: 'claude', displayName: 'Claude' }) const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() }) const r = only(report, 'claude') expect(r.envOverrides.some(o => o.name === varName)).toBe(false) } finally { if (prev === undefined) delete process.env[varName] else process.env[varName] = prev } }) } // Every credential in SECRET_ENV_VARS must be redacted at collect time so // neither the text render nor the JSON report can leak it (Ruling 2 of lane // 04). Table-driven over both, so a credential added to the set without a // redaction test fails here instead of leaking into a bug report. for (const varName of ['AI_GATEWAY_API_KEY', 'VERCEL_OIDC_TOKEN']) { it(`redacts credential values (${varName}) from overrides, the table render, and the JSON report`, async () => { const secret = `sk-live-${varName}-value-12345` const prev = process.env[varName] const sibling = varName === 'AI_GATEWAY_API_KEY' ? 'VERCEL_OIDC_TOKEN' : 'AI_GATEWAY_API_KEY' const prevSibling = process.env[sibling] process.env[varName] = secret // Isolate the case under test: a stray ambient sibling must not change // what this case observes. delete process.env[sibling] try { const provider = fakeProvider({ name: 'vercel-gateway', displayName: 'Vercel AI Gateway', network: true }) const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() }) const r = only(report, 'vercel-gateway') // The "is this credential set?" diagnostic is useful; the value is a // live secret and must never leave doctor (Ruling 2 of lane 04). expect(r.envOverrides).toContainEqual({ name: varName, value: '' }) expect(r.envOverrides.some(o => o.value.includes(secret))).toBe(false) const table = renderDoctorTable(report, { color: false }) expect(table).toContain(`${varName}=`) expect(table).not.toContain(secret) expect(renderDoctorJson(report)).not.toContain(secret) } finally { if (prev === undefined) delete process.env[varName] else process.env[varName] = prev if (prevSibling === undefined) delete process.env[sibling] else process.env[sibling] = prevSibling } }) } // CODEBURN_CURSOR_MAX_BUBBLES caps how many bubbles Cursor parses // (src/providers/cursor.ts:692) and KIMI_MODEL_NAME renames the model // attributed to Kimi sessions (src/providers/kimi.ts:155): both are // fingerprinted but cannot explain why nothing was discovered, so the // verdict must not name them — while Details still lists them, because they // ARE overrides in force. Each is asserted through the provider that // declares it. for (const [varName, providerName, displayName, value] of [ ['CODEBURN_CURSOR_MAX_BUBBLES', 'cursor', 'Cursor', '5000'], ['KIMI_MODEL_NAME', 'kimi', 'Kimi', 'kimi-latest-920'], ] as const) { it(`does not blame ${varName} for an empty ${displayName} (not a discovery path)`, async () => { const prev = process.env[varName] process.env[varName] = value try { const provider = fakeProvider({ name: providerName, displayName }) const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() }) const r = only(report, providerName) expect(r.envOverrides).toContainEqual({ name: varName, value }) expect(r.verdict).not.toContain(varName) const table = renderDoctorTable(report, { color: false }) expect(table).toContain(`${varName}=${value}`) } finally { if (prev === undefined) delete process.env[varName] else process.env[varName] = 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 => [{ 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 } }) }) // ── Claude transcript retention note ─────────────────────────────────────── describe('collectDoctorReport - claude retention note', () => { async function withClaudeConfig(settings: string | null, fn: () => Promise): Promise { const prev = process.env['CLAUDE_CONFIG_DIR'] const prevMulti = process.env['CLAUDE_CONFIG_DIRS'] delete process.env['CLAUDE_CONFIG_DIRS'] const dir = join(tmpDir, 'claude-config') await mkdir(dir, { recursive: true }) if (settings !== null) await writeFile(join(dir, 'settings.json'), settings) process.env['CLAUDE_CONFIG_DIR'] = dir try { await fn() } finally { if (prev === undefined) delete process.env['CLAUDE_CONFIG_DIR'] else process.env['CLAUDE_CONFIG_DIR'] = prev if (prevMulti !== undefined) process.env['CLAUDE_CONFIG_DIRS'] = prevMulti } } it('reports an explicit cleanupPeriodDays and renders it as preserved when long', async () => { await withClaudeConfig(JSON.stringify({ cleanupPeriodDays: 3650 }), async () => { const provider = fakeProvider({ name: 'claude' }) const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() }) expect(report.claudeRetention).toMatchObject({ effectiveDays: 3650, configured: true }) const table = renderDoctorTable(report, { color: false }) expect(table).toContain('deletes transcripts after 3650 days') expect(table).toContain('per-session detail is preserved') }) }) it('reports the 30-day default and renders a warning with the settings path', async () => { await withClaudeConfig(JSON.stringify({ theme: 'dark' }), async () => { const provider = fakeProvider({ name: 'claude' }) const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() }) expect(report.claudeRetention).toMatchObject({ effectiveDays: 30, configured: false }) const table = renderDoctorTable(report, { color: false }) expect(table).toContain('deletes transcripts after 30 days') expect(table).toContain('cleanupPeriodDays not set') expect(table).toContain('"cleanupPeriodDays": 3650') expect(table).toContain(report.claudeRetention!.settingsPath) }) }) it('emits no note when claude is not in the report or has no settings file', async () => { await withClaudeConfig(null, async () => { const claudeless = await collectDoctorReport('all', { providers: [fakeProvider({ name: 'codex' })], cache: emptyCache() }) expect(claudeless.claudeRetention).toBeUndefined() const noSettings = await collectDoctorReport('all', { providers: [fakeProvider({ name: 'claude' })], cache: emptyCache() }) expect(noSettings.claudeRetention).toBeUndefined() expect(renderDoctorTable(noSettings, { color: false })).not.toContain('deletes transcripts') }) }) })