mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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.
181 lines
6.7 KiB
TypeScript
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',
|
|
}),
|
|
]),
|
|
)
|
|
})
|
|
})
|