mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Diagnostics exports previously preserved only compact summaries, which made user-uploaded issue bundles hard to debug. This records richer sanitized details from server, CLI, SDK, browser, React, and desktop API failure paths while keeping request bodies and secrets out of client-side reports. Constraint: Diagnostic bundles must be useful for GitHub issues without including chat contents, file contents, full environments, or API keys. Rejected: Only expand the Settings UI summary | the exported bundle would still miss hidden runtime failures. Confidence: high Scope-risk: moderate Directive: Keep diagnostics write paths best-effort and non-blocking; do not add request-body capture without a redaction review. Tested: bun test src/server/__tests__/diagnostics-service.test.ts src/server/__tests__/conversation-service.test.ts Tested: cd desktop && bun run test src/api/client.test.ts src/__tests__/diagnosticsSettings.test.tsx --run Tested: bun run check:server Tested: bun run check:desktop Tested: agent-browser E2E on ports 37652/41752 captured client_unhandled_rejection and exported a redacted tar.gz bundle
165 lines
6.8 KiB
TypeScript
165 lines
6.8 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 { gunzipSync } from 'node:zlib'
|
|
import { handleDiagnosticsApi } from '../api/diagnostics.js'
|
|
import { DiagnosticsService, diagnosticsService } from '../services/diagnosticsService.js'
|
|
|
|
let tmpDir: string
|
|
let originalConfigDir: string | undefined
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-diagnostics-test-'))
|
|
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
})
|
|
|
|
afterEach(async () => {
|
|
diagnosticsService.restoreConsoleCaptureForTests()
|
|
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): { req: Request; url: URL; segments: string[] } {
|
|
const url = new URL(urlStr, 'http://localhost:3456')
|
|
const req = new Request(url.toString(), { method })
|
|
const segments = url.pathname.split('/').filter(Boolean)
|
|
return { req, url, segments }
|
|
}
|
|
|
|
describe('DiagnosticsService', () => {
|
|
test('writes sanitized structured events and runtime error summaries', async () => {
|
|
const service = new DiagnosticsService()
|
|
await service.recordEvent({
|
|
type: 'cli_start_failed',
|
|
severity: 'error',
|
|
sessionId: 'session-1',
|
|
summary: 'Authorization: Bearer sk-secret-token /Users/example/path',
|
|
details: {
|
|
apiKey: 'sk-secret',
|
|
url: 'https://api.example.com?api_key=secret-value',
|
|
nested: { message: `home=${os.homedir()}` },
|
|
},
|
|
})
|
|
|
|
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8')
|
|
expect(raw).toContain('cli_start_failed')
|
|
expect(raw).toContain('[REDACTED]')
|
|
expect(raw).not.toContain('sk-secret')
|
|
expect(raw).not.toContain(os.homedir())
|
|
|
|
const runtime = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8')
|
|
expect(runtime).toContain('cli_start_failed')
|
|
expect(runtime).toContain('"nested"')
|
|
expect(runtime).toContain('[REDACTED]')
|
|
expect(runtime).not.toContain('sk-secret-token')
|
|
})
|
|
|
|
test('exports a single diagnostics tarball without provider secrets', async () => {
|
|
const service = new DiagnosticsService()
|
|
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'cc-haha', 'providers.json'),
|
|
JSON.stringify({
|
|
activeId: 'provider-1',
|
|
providers: [{
|
|
id: 'provider-1',
|
|
name: 'Test Provider',
|
|
presetId: 'custom',
|
|
apiKey: 'sk-provider-secret',
|
|
baseUrl: 'https://api.example.com/anthropic',
|
|
apiFormat: 'anthropic',
|
|
models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' },
|
|
}],
|
|
}),
|
|
'utf-8',
|
|
)
|
|
await service.recordEvent({
|
|
type: 'provider_test_failed',
|
|
severity: 'warn',
|
|
sessionId: 'session-abc',
|
|
summary: 'provider failed with token=provider-secret',
|
|
details: { accessToken: 'provider-secret' },
|
|
})
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'cc-haha', 'diagnostics', 'cli-diagnostics.jsonl'),
|
|
'{"event":"cli_streaming_idle_timeout","data":{"authorization":"Bearer provider-secret"}}\n',
|
|
'utf-8',
|
|
)
|
|
|
|
const bundle = await service.exportBundle()
|
|
expect(bundle.path).toEndWith('.tar.gz')
|
|
const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8')
|
|
expect(archiveText).toContain('README.txt')
|
|
expect(archiveText).toContain('recent-errors.md')
|
|
expect(archiveText).toContain('cli-diagnostics.jsonl')
|
|
expect(archiveText).toContain('providers-summary.json')
|
|
expect(archiveText).toContain('sessions-summary.json')
|
|
expect(archiveText).toContain('cli_streaming_idle_timeout')
|
|
expect(archiveText).toContain('Test Provider')
|
|
expect(archiveText).toContain('api.example.com')
|
|
expect(archiveText).not.toContain('sk-provider-secret')
|
|
expect(archiveText).not.toContain('provider-secret')
|
|
})
|
|
})
|
|
|
|
describe('diagnostics API', () => {
|
|
test('returns status, events, export path, and supports clearing logs', async () => {
|
|
const service = diagnosticsService
|
|
await service.recordEvent({
|
|
type: 'api_unhandled_error',
|
|
severity: 'error',
|
|
summary: 'boom',
|
|
})
|
|
|
|
const statusReq = makeRequest('GET', '/api/diagnostics/status')
|
|
const statusRes = await handleDiagnosticsApi(statusReq.req, statusReq.url, statusReq.segments)
|
|
expect(statusRes.status).toBe(200)
|
|
const status = await statusRes.json() as { logDir: string; cliDiagnosticsPath: string; recentErrorCount: number }
|
|
expect(status.logDir).toContain(path.join('cc-haha', 'diagnostics'))
|
|
expect(status.cliDiagnosticsPath).toContain('cli-diagnostics.jsonl')
|
|
expect(status.recentErrorCount).toBe(1)
|
|
|
|
const eventsReq = makeRequest('GET', '/api/diagnostics/events?limit=10')
|
|
const eventsRes = await handleDiagnosticsApi(eventsReq.req, eventsReq.url, eventsReq.segments)
|
|
expect(eventsRes.status).toBe(200)
|
|
const events = await eventsRes.json() as { events: Array<{ type: string }> }
|
|
expect(events.events[0].type).toBe('api_unhandled_error')
|
|
|
|
const clientEventReq = new Request('http://localhost:3456/api/diagnostics/events', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
type: 'client_unhandled_rejection',
|
|
severity: 'error',
|
|
summary: 'frontend exploded token=client-secret',
|
|
details: { accessToken: 'client-secret', stack: 'Error: boom' },
|
|
}),
|
|
})
|
|
const clientEventUrl = new URL(clientEventReq.url)
|
|
const clientEventRes = await handleDiagnosticsApi(
|
|
clientEventReq,
|
|
clientEventUrl,
|
|
clientEventUrl.pathname.split('/').filter(Boolean),
|
|
)
|
|
expect(clientEventRes.status).toBe(200)
|
|
const clientEvents = await service.readRecentEvents(10)
|
|
expect(clientEvents[0].type).toBe('client_unhandled_rejection')
|
|
expect(JSON.stringify(clientEvents[0])).toContain('[REDACTED]')
|
|
expect(JSON.stringify(clientEvents[0])).not.toContain('client-secret')
|
|
|
|
const exportReq = makeRequest('POST', '/api/diagnostics/export')
|
|
const exportRes = await handleDiagnosticsApi(exportReq.req, exportReq.url, exportReq.segments)
|
|
expect(exportRes.status).toBe(200)
|
|
const exported = await exportRes.json() as { bundle: { path: string } }
|
|
await expect(fs.stat(exported.bundle.path)).resolves.toBeTruthy()
|
|
|
|
const clearReq = makeRequest('DELETE', '/api/diagnostics')
|
|
const clearRes = await handleDiagnosticsApi(clearReq.req, clearReq.url, clearReq.segments)
|
|
expect(clearRes.status).toBe(200)
|
|
expect(await service.readRecentEvents()).toEqual([])
|
|
})
|
|
})
|