mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Diagnostics should capture fatal server failures without replacing the terminal stderr signal that developers rely on during local startup. Fatal uncaught exceptions now print their stack to stderr, persist the diagnostic event, and exit non-zero so repeated server starts expose port conflicts immediately. Constraint: Startup diagnostics must still write sanitized runtime events for export bundles. Rejected: Only document checking runtime-errors.log | developers should not lose the standard stderr failure path. Confidence: high Scope-risk: narrow Directive: Do not install process-level exception handlers that swallow fatal startup errors without preserving stderr and non-zero exit behavior. Tested: bun test src/server/__tests__/diagnostics-service.test.ts Tested: bun run check:server Tested: SERVER_PORT=3456 bun run src/server/index.ts returned exit=1 and printed the port-conflict stack while diagnostics recorded the event
240 lines
9.2 KiB
TypeScript
240 lines
9.2 KiB
TypeScript
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([])
|
|
})
|
|
})
|