mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-27 01:00:36 +00:00
Sessions skipped for an unreadable header, a filename/header generation mismatch, an ambiguous generation filename, or missing zstd support now call onSkippedVersion, so doctor reports ERRORS instead of OK while dropping sessions. Missing zstd support is explained once from discovery rather than producing one unreadable-header line per compressed log. Path-naming notices dedup on the message kind and print at most three example paths, instead of one unbounded line per file. The filename generation now matches v(\d+): session.v0 is the unversioned generation, session.v03 is generation 3, and a duplicate-encoded or non-safe generation is skipped and counted rather than silently invisible.
541 lines
24 KiB
TypeScript
541 lines
24 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } 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 { createDshProvider } from '../src/providers/dsh.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<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('reports every DSH session skipped for an unknown highest generation', async () => {
|
||
const warnings = vi.spyOn(process.stderr, 'write')
|
||
try {
|
||
for (const id of ['first', 'second']) {
|
||
const dir = join(tmpDir, 'sessions', '--fixture--', id)
|
||
await mkdir(dir, { recursive: true })
|
||
await writeFile(join(dir, 'session.jsonl'), JSON.stringify({ type: 'session', version: 0 }))
|
||
await writeFile(join(dir, 'session.v99.jsonl'), 'unreadable future format')
|
||
}
|
||
const provider = createDshProvider(tmpDir)
|
||
const options = { providers: [provider], cache: emptyCache(), dailyCacheDays: [], launchers: [] }
|
||
for (let scan = 0; scan < 2; scan++) {
|
||
const report = await collectDoctorReport('dsh', options)
|
||
expect(only(report, 'dsh')).toMatchObject({ status: 'errors', skippedVersionCount: 2, candidatesFound: 0 })
|
||
expect(renderDoctorTable(report)).toContain('2 sessions skipped')
|
||
expect(JSON.parse(renderDoctorJson(report)).providers[0].skippedVersionCount).toBe(2)
|
||
}
|
||
expect(warnings.mock.calls.filter(([message]) => String(message).includes('session format version 99;'))).toHaveLength(1)
|
||
} finally {
|
||
warnings.mockRestore()
|
||
}
|
||
})
|
||
it('reports DSH sessions dropped for an unreadable or mismatched header', async () => {
|
||
const warnings = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||
try {
|
||
const unreadable = join(tmpDir, 'sessions', '--fixture--', 'unreadable')
|
||
const mismatch = join(tmpDir, 'sessions', '--fixture--', 'mismatch')
|
||
await mkdir(unreadable, { recursive: true })
|
||
await mkdir(mismatch, { recursive: true })
|
||
await writeFile(join(unreadable, 'session.v3.jsonl'), '{broken')
|
||
await writeFile(join(mismatch, 'session.v3.jsonl'), JSON.stringify({ type: 'session', version: 2 }))
|
||
|
||
const report = await collectDoctorReport('dsh', {
|
||
providers: [createDshProvider(tmpDir)], cache: emptyCache(), dailyCacheDays: [], launchers: [],
|
||
})
|
||
|
||
expect(only(report, 'dsh')).toMatchObject({ status: 'errors', skippedVersionCount: 2, candidatesFound: 0 })
|
||
expect(renderDoctorTable(report)).toContain('2 sessions skipped')
|
||
} finally {
|
||
warnings.mockRestore()
|
||
}
|
||
})
|
||
|
||
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: '<set>' })
|
||
expect(r.envOverrides.some(o => o.value.includes(secret))).toBe(false)
|
||
const table = renderDoctorTable(report, { color: false })
|
||
expect(table).toContain(`${varName}=<set>`)
|
||
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<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
|
||
}
|
||
})
|
||
})
|
||
|
||
// ── Claude transcript retention note ───────────────────────────────────────
|
||
describe('collectDoctorReport - claude retention note', () => {
|
||
async function withClaudeConfig(settings: string | null, fn: () => Promise<void>): Promise<void> {
|
||
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')
|
||
})
|
||
})
|
||
})
|