codev / src /server /__tests__ /diagnostics-service.test.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame Contribute Delete
9.45 kB
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import { createServer } from 'node:net'
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 }
}
async function getPort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate a local port')))
return
}
server.close(() => resolve(address.port))
})
})
}
async function waitForHttp(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
let lastError = ''
while (Date.now() < deadline) {
try {
const response = await fetch(url)
if (response.ok) return
lastError = `HTTP ${response.status}`
} catch (error) {
lastError = error instanceof Error ? error.message : String(error)
}
await Bun.sleep(100)
}
throw new Error(`Timed out waiting for ${url}${lastError ? ` (${lastError})` : ''}`)
}
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')
})
test('keeps fatal startup errors visible on stderr while recording diagnostics', async () => {
const port = await getPort()
const serverArgs = ['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)]
const env = {
...process.env,
CLAUDE_CONFIG_DIR: tmpDir,
}
const server = Bun.spawn(serverArgs, {
cwd: process.cwd(),
env,
stdout: 'ignore',
stderr: 'ignore',
})
try {
await waitForHttp(`http://127.0.0.1:${port}/health`, 10_000)
const duplicate = Bun.spawn(serverArgs, {
cwd: process.cwd(),
env,
stdout: 'pipe',
stderr: 'pipe',
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(duplicate.stdout).text(),
new Response(duplicate.stderr).text(),
duplicate.exited,
])
expect(exitCode).toBe(1)
expect(stdout).toBe('')
expect(stderr).toContain('[Server] Uncaught exception:')
expect(stderr).toContain(`Failed to start server. Is port ${port} in use?`)
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8')
expect(raw).toContain('server_uncaught_exception')
expect(raw).toContain(`Failed to start server. Is port ${port} in use?`)
} finally {
server.kill()
await server.exited.catch(() => undefined)
}
})
})
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([])
})
})