mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
The diagnostics panel filled with red ERROR/WARN entries during normal use because the capture layer treated "wrote to console" as "something failed", with no severity gatekeeping. Much of the noise was also test runs leaking into the user's real ~/.claude/cc-haha/diagnostics. - diagnosticsService: default unclassified events to info (not error); drop writes under NODE_ENV=test when no CLAUDE_CONFIG_DIR is set; document the console-capture contract (expected states use console.debug/info). - index: don't install console/process capture under bun test. - api/diagnostics: an ingested event with missing severity defaults to info. - oauthRefreshLog (new): token refresh failure is gracefully handled and is never an error — expected expiry (401/403/revoked) logs at debug, anything else at warn. Wired into both Haha OAuth services. - conversationService: classify cli_runtime_exit severity by exit code, so clean/SIGTERM/SIGKILL exits are info and only abnormal codes stay error (real "chat died" crashes remain perceivable). - ws/handler: streaming partial tool-input JSON is normal — debug, not warn. Tests: oauth-refresh-log + cli-exit-severity unit tests; diagnostics-service covers the info default and the test-isolation guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, spyOn, 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',
|
|
proxyUrl: 'https://proxy-user:p%40ss@example.com:8443/api',
|
|
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).toContain('https://[REDACTED]@example.com:8443/api')
|
|
expect(raw).not.toContain('sk-secret')
|
|
expect(raw).not.toContain('proxy-user')
|
|
expect(raw).not.toContain('p%40ss')
|
|
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('defaults an unclassified event to info, not error', async () => {
|
|
const service = new DiagnosticsService()
|
|
await service.recordEvent({ type: 'some_unclassified_event', summary: 'no severity given' })
|
|
|
|
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8')
|
|
const event = JSON.parse(raw.trim().split('\n').at(-1)!)
|
|
expect(event.severity).toBe('info')
|
|
|
|
// info events stay out of the warning/error runtime log
|
|
await expect(
|
|
fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8'),
|
|
).rejects.toThrow()
|
|
})
|
|
|
|
test('drops events under NODE_ENV=test when no CLAUDE_CONFIG_DIR is set (no real-home pollution)', async () => {
|
|
const service = new DiagnosticsService()
|
|
const appendSpy = spyOn(fs, 'appendFile')
|
|
const savedConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
try {
|
|
await service.recordEvent({ type: 'leaked_test_event', severity: 'error', summary: 'should not be written' })
|
|
expect(appendSpy).not.toHaveBeenCalled()
|
|
} finally {
|
|
appendSpy.mockRestore()
|
|
if (savedConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
|
else process.env.CLAUDE_CONFIG_DIR = savedConfigDir
|
|
}
|
|
})
|
|
|
|
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)]
|
|
// This spawns a *real* server, not the in-process test runner. Strip the
|
|
// inherited NODE_ENV=test so it installs console/process capture the way a
|
|
// production server does (capture is intentionally skipped under test).
|
|
const env = {
|
|
...process.env,
|
|
CLAUDE_CONFIG_DIR: tmpDir,
|
|
}
|
|
delete (env as Record<string, string | undefined>).NODE_ENV
|
|
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([])
|
|
})
|
|
})
|