mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
Round 2 of the independent review proved five things by mutation: it broke the behavior and the tests stayed green. Every one is now pinned. The most important invariant in this change was the least guarded. Copilot must have NO entry in PROVIDER_ENV_VARS - declaring any of its nine reads moves its fingerprint and re-opens the durable history-loss path - but only one of the nine was covered, so declaring any of the other eight passed the whole suite. Now the absence of the entry is asserted directly, and all nine vars are table-tested for fingerprint stability. Doctor stops blaming parse-only overrides for a failed discovery. CODEBURN_CURSOR_MAX_BUBBLES caps how many bubbles Cursor parses and KIMI_MODEL_NAME renames an attributed model; neither relocates anything, so "NOTHING FOUND (override CODEBURN_CURSOR_MAX_BUBBLES set...)" pointed the user at the wrong thing. Both join NON_DISCOVERY_ENV_VARS, which exists for exactly this, and both still appear in Details - only the verdict's blame line changes. The secret-redaction and ambient-suppression tests are table-driven over both names each covers, since removing either second name (VERCEL_OIDC_TOKEN, LOCALAPPDATA) previously leaked or surfaced it with every test still passing. The changelog no longer claims a one-time re-parse for the Vercel gateway: it is a network provider re-fetched on every writable run, so its declaration is a read-only-path correction, not a migration. Fourteen file-backed providers migrate once.
497 lines
22 KiB
TypeScript
497 lines
22 KiB
TypeScript
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<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
|
||
}
|
||
})
|
||
|
||
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')
|
||
})
|
||
})
|
||
})
|