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 { 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 { 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([]) }) })