cc-haha/src/server/__tests__/doctor-service.test.ts
程序员阿江(Relakkes) 7ae885a114 fix: add safe Doctor rescue path for desktop upgrades
Online upgrades can strand users on stale desktop UI state or malformed local persistence. This adds a deny-by-default Doctor path that resets only regenerable desktop UI state, reports protected local files with redacted metadata, and keeps protected repair as a dry-run no-op until a reviewed backup-first flow exists.

Constraint: Chat transcripts, model/provider config, Skills, MCP, IM bindings, adapter sessions, OAuth tokens, plugins, and team/session records are user-owned protected state.
Rejected: Automatically rewrite malformed protected JSON | unsafe without schema-specific migrations and backups.
Rejected: Continue relying only on startup migrations | users need an explicit recovery action after a white screen.
Confidence: high
Scope-risk: moderate
Directive: Keep Doctor repair deny-by-default; do not mutate protected state without an explicit reviewed backup-first manual repair flow.
Tested: bun test src/server/__tests__/doctor-service.test.ts
Tested: cd desktop && bun run test src/components/ErrorBoundary.test.tsx src/lib/doctorRepair.test.ts src/__tests__/diagnosticsSettings.test.tsx src/components/layout/StartupErrorView.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:server
Tested: bun run verify (failed only existing agent-utils coverage baseline; changed-lines coverage 97.62%)
Not-tested: Packaged desktop manual Doctor click path.
2026-05-07 00:04:06 +08:00

181 lines
6.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { handleDoctorApi } from '../api/doctor.js'
import { handleApiRequest } from '../router.js'
import { DoctorService } from '../services/doctorService.js'
let tmpDir: string
let homeDir: string
let configDir: string
let projectRoot: string
let originalConfigDir: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'doctor-service-test-'))
homeDir = path.join(tmpDir, 'home')
configDir = path.join(homeDir, '.claude')
projectRoot = path.join(tmpDir, 'workspace', 'demo-project')
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = configDir
await fs.mkdir(path.join(configDir, 'projects', 'demo-project'), { recursive: true })
await fs.mkdir(path.join(configDir, 'skills', 'alpha-skill'), { recursive: true })
await fs.mkdir(path.join(configDir, 'cc-haha'), { recursive: true })
await fs.mkdir(path.join(projectRoot, '.claude', 'skills', 'beta-skill'), { recursive: true })
await fs.writeFile(path.join(configDir, 'settings.json'), '{"defaultMode":', 'utf-8')
await fs.writeFile(path.join(projectRoot, '.claude', 'settings.json'), '{"model":', 'utf-8')
await fs.writeFile(
path.join(configDir, 'projects', 'demo-project', 'session-1.jsonl'),
'{"type":"message"}\n{bad json}\n',
'utf-8',
)
await fs.writeFile(
path.join(configDir, 'cc-haha', 'providers.json'),
JSON.stringify({ activeId: null, providers: [{ id: 'provider-1' }] }),
'utf-8',
)
await fs.writeFile(
path.join(configDir, 'skills', 'alpha-skill', 'SKILL.md'),
'# Alpha\n',
'utf-8',
)
await fs.writeFile(
path.join(projectRoot, '.claude', 'skills', 'beta-skill', 'SKILL.md'),
'# Beta\n',
'utf-8',
)
})
afterEach(async () => {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
await fs.rm(tmpDir, { recursive: true, force: true })
})
function makeRequest(
method: string,
urlStr: string,
body?: Record<string, unknown>,
): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const init: RequestInit = { method }
if (body) {
init.headers = { 'Content-Type': 'application/json' }
init.body = JSON.stringify(body)
}
const req = new Request(url.toString(), init)
const segments = url.pathname.split('/').filter(Boolean)
return { req, url, segments }
}
describe('DoctorService', () => {
test('report redacts filesystem paths and lists protected skipped items', async () => {
const service = new DoctorService({ configDir, homeDir, projectRoot })
const report = await service.getReport()
const serialized = JSON.stringify(report)
expect(serialized).not.toContain(tmpDir)
expect(serialized).not.toContain(projectRoot)
const userSettings = report.items.find((item) => item.path === '~/.claude/settings.json')
expect(userSettings).toBeDefined()
expect(userSettings?.protected).toBe(true)
expect(userSettings?.status).toBe('invalid_json')
const projectSettings = report.items.find(
(item) => item.path === '<project>/.claude/settings.json',
)
expect(projectSettings).toBeDefined()
expect(projectSettings?.protected).toBe(true)
expect(projectSettings?.status).toBe('invalid_json')
const sessionJsonl = report.items.find(
(item) => item.path === '~/.claude/projects/demo-project/session-1.jsonl',
)
expect(sessionJsonl).toBeDefined()
expect(sessionJsonl?.protected).toBe(true)
expect(sessionJsonl?.status).toBe('invalid_jsonl')
expect(sessionJsonl?.lineCount).toBe(2)
expect(sessionJsonl?.invalidLineCount).toBe(1)
expect(report.protectedSkips).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: '~/.claude/settings.json', reason: 'protected' }),
expect.objectContaining({ path: '<project>/.claude/settings.json', reason: 'protected' }),
expect.objectContaining({
path: '~/.claude/projects/demo-project/session-1.jsonl',
reason: 'protected',
}),
]),
)
})
test('safe repair skips protected malformed files without modifying them', async () => {
const service = new DoctorService({ configDir, homeDir, projectRoot })
const userSettingsPath = path.join(configDir, 'settings.json')
const projectSettingsPath = path.join(projectRoot, '.claude', 'settings.json')
const beforeUser = await fs.readFile(userSettingsPath, 'utf-8')
const beforeProject = await fs.readFile(projectSettingsPath, 'utf-8')
const result = await service.repair()
expect(result.mutated).toBe(false)
expect(result.operations).toEqual([])
expect(result.skips).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: '~/.claude/settings.json', reason: 'protected' }),
expect.objectContaining({ path: '<project>/.claude/settings.json', reason: 'protected' }),
]),
)
expect(await fs.readFile(userSettingsPath, 'utf-8')).toBe(beforeUser)
expect(await fs.readFile(projectSettingsPath, 'utf-8')).toBe(beforeProject)
})
})
describe('doctor API', () => {
test('returns a report and dry-run repair result', async () => {
const reportReq = makeRequest(
'GET',
`/api/doctor/report?cwd=${encodeURIComponent(projectRoot)}`,
)
const reportRes = await handleDoctorApi(reportReq.req, reportReq.url, reportReq.segments)
expect(reportRes.status).toBe(200)
const reportBody = await reportRes.json() as {
report: { summary: { protectedCount: number } }
}
expect(reportBody.report.summary.protectedCount).toBeGreaterThan(0)
const repairReq = makeRequest('POST', '/api/doctor/repair', { cwd: projectRoot })
const repairRes = await handleDoctorApi(repairReq.req, repairReq.url, repairReq.segments)
expect(repairRes.status).toBe(200)
const repairBody = await repairRes.json() as { result: { mutated: boolean } }
expect(repairBody.result.mutated).toBe(false)
})
test('routes doctor requests through the main API router', async () => {
const url = new URL(
`/api/doctor/report?cwd=${encodeURIComponent(projectRoot)}`,
'http://localhost:3456',
)
const res = await handleApiRequest(new Request(url.toString(), { method: 'GET' }), url)
expect(res.status).toBe(200)
const body = await res.json() as {
report: { items: Array<{ path: string; status: string }> }
}
expect(body.report.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '~/.claude/settings.json',
status: 'invalid_json',
}),
]),
)
})
})