test: 添加 server 模块单元测试
This commit is contained in:
parent
7814a6f50a
commit
c55bd43f2e
55
src/server/__tests__/errors.test.ts
Normal file
55
src/server/__tests__/errors.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { ServeError, badRequest, unauthorized, notFound, conflict, tooManySessions, sessionError } from '../errors.js'
|
||||
|
||||
describe('ServeError', () => {
|
||||
test('creates error with status, code, message', () => {
|
||||
const err = new ServeError(400, 'BAD_REQUEST', 'test message')
|
||||
expect(err.status).toBe(400)
|
||||
expect(err.code).toBe('BAD_REQUEST')
|
||||
expect(err.message).toBe('test message')
|
||||
expect(err.name).toBe('ServeError')
|
||||
})
|
||||
|
||||
test('json() returns correct shape', () => {
|
||||
const err = new ServeError(404, 'NOT_FOUND', 'not here')
|
||||
expect(err.json()).toEqual({ error: 'NOT_FOUND', message: 'not here' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('error factories', () => {
|
||||
test('badRequest', () => {
|
||||
const err = badRequest('bad')
|
||||
expect(err.status).toBe(400)
|
||||
expect(err.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
test('unauthorized', () => {
|
||||
const err = unauthorized()
|
||||
expect(err.status).toBe(401)
|
||||
expect(err.code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
|
||||
test('notFound', () => {
|
||||
const err = notFound('missing')
|
||||
expect(err.status).toBe(404)
|
||||
expect(err.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
test('conflict', () => {
|
||||
const err = conflict('dup')
|
||||
expect(err.status).toBe(409)
|
||||
expect(err.code).toBe('CONFLICT')
|
||||
})
|
||||
|
||||
test('tooManySessions', () => {
|
||||
const err = tooManySessions('max')
|
||||
expect(err.status).toBe(429)
|
||||
expect(err.code).toBe('TOO_MANY_SESSIONS')
|
||||
})
|
||||
|
||||
test('sessionError', () => {
|
||||
const err = sessionError('crash')
|
||||
expect(err.status).toBe(503)
|
||||
expect(err.code).toBe('SESSION_ERROR')
|
||||
})
|
||||
})
|
||||
122
src/server/__tests__/eventBus.test.ts
Normal file
122
src/server/__tests__/eventBus.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, expect, test, beforeEach } from 'bun:test'
|
||||
import { EventBus } from '../eventBus.js'
|
||||
|
||||
describe('EventBus', () => {
|
||||
let bus: EventBus
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus()
|
||||
})
|
||||
|
||||
test('addClient returns a client id', () => {
|
||||
const writer = { writeSSE: async () => {} }
|
||||
const id = bus.addClient(writer)
|
||||
expect(typeof id).toBe('string')
|
||||
expect(id.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('removeClient removes the client', () => {
|
||||
const writer = { writeSSE: async () => {} }
|
||||
const id = bus.addClient(writer)
|
||||
expect(bus.clientCount()).toBe(1)
|
||||
bus.removeClient(id)
|
||||
expect(bus.clientCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('publish sends events to all clients', async () => {
|
||||
const received: Array<{ event: string; data: string }> = []
|
||||
const writer = {
|
||||
writeSSE: async (opts: { event: string; data: string }) => {
|
||||
received.push(opts)
|
||||
},
|
||||
}
|
||||
bus.addClient(writer)
|
||||
bus.publish('test', { hello: 'world' })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
const dataEvents = received.filter(r => r.event === 'test')
|
||||
expect(dataEvents.length).toBe(1)
|
||||
expect(JSON.parse(dataEvents[0].data)).toEqual({ hello: 'world' })
|
||||
})
|
||||
|
||||
test('publish buffers events for late-joining clients', async () => {
|
||||
bus.publish('test1', { a: 1 })
|
||||
bus.publish('test2', { b: 2 })
|
||||
|
||||
const received: Array<{ event: string; data: string }> = []
|
||||
const writer = {
|
||||
writeSSE: async (opts: { event: string; data: string }) => {
|
||||
received.push(opts)
|
||||
},
|
||||
}
|
||||
bus.addClient(writer)
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(received.length).toBe(3) // connected + 2 buffered
|
||||
})
|
||||
|
||||
test('session_id filter skips non-matching events', async () => {
|
||||
const received: Array<{ event: string; data: string }> = []
|
||||
const writer = {
|
||||
writeSSE: async (opts: { event: string; data: string }) => {
|
||||
received.push(opts)
|
||||
},
|
||||
}
|
||||
bus.addClient(writer, 'session-abc')
|
||||
|
||||
bus.publish('session.message', { session_id: 'session-abc', type: 'assistant' })
|
||||
bus.publish('session.message', { session_id: 'session-xyz', type: 'assistant' })
|
||||
bus.publish('heartbeat', { type: 'server.heartbeat' })
|
||||
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
const dataEvents = received.filter(r => r.event !== 'connected')
|
||||
expect(dataEvents.length).toBe(2) // session-abc + heartbeat (no session_id)
|
||||
})
|
||||
|
||||
test('publishSessionEvent prefixes event with session.', async () => {
|
||||
const received: Array<{ event: string; data: string }> = []
|
||||
const writer = {
|
||||
writeSSE: async (opts: { event: string; data: string }) => {
|
||||
received.push(opts)
|
||||
},
|
||||
}
|
||||
bus.addClient(writer)
|
||||
bus.publishSessionEvent('sid-1', 'ready', { status: 'running' })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
const readyEvent = received.find(r => r.event === 'session.ready')
|
||||
expect(readyEvent).toBeDefined()
|
||||
expect(JSON.parse(readyEvent!.data)).toEqual({ session_id: 'sid-1', status: 'running' })
|
||||
})
|
||||
|
||||
test('startHeartbeat publishes heartbeat events', async () => {
|
||||
const received: Array<{ event: string; data: string }> = []
|
||||
const writer = {
|
||||
writeSSE: async (opts: { event: string; data: string }) => {
|
||||
received.push(opts)
|
||||
},
|
||||
}
|
||||
bus.addClient(writer)
|
||||
bus.startHeartbeat(50)
|
||||
await new Promise(r => setTimeout(r, 120))
|
||||
bus.stopHeartbeat()
|
||||
const heartbeats = received.filter(r => r.event === 'heartbeat')
|
||||
expect(heartbeats.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('destroy clears all state', () => {
|
||||
const writer = { writeSSE: async () => {} }
|
||||
bus.addClient(writer)
|
||||
bus.startHeartbeat(10000)
|
||||
bus.destroy()
|
||||
expect(bus.clientCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('buffer does not exceed bufferSize', () => {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
bus.publish('test', { i })
|
||||
}
|
||||
const writer = { writeSSE: async () => {} }
|
||||
bus.addClient(writer)
|
||||
// buffer is 100, so only last 100 should be replayed
|
||||
// We can't directly check buffer, but no crash = ok
|
||||
expect(bus.clientCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
147
src/server/__tests__/sessionHandle.test.ts
Normal file
147
src/server/__tests__/sessionHandle.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, expect, test, mock } from 'bun:test'
|
||||
import { SessionHandle } from '../sessionHandle.js'
|
||||
import type { EventBus } from '../eventBus.js'
|
||||
|
||||
function createMockEventBus(): EventBus {
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = []
|
||||
return {
|
||||
publishSessionEvent(sessionId: string, event: string, data: Record<string, unknown>) {
|
||||
events.push({ event, data: { session_id: sessionId, ...data } })
|
||||
},
|
||||
publish() {},
|
||||
addClient() { return '' },
|
||||
removeClient() {},
|
||||
startHeartbeat() {},
|
||||
stopHeartbeat() {},
|
||||
clientCount() { return 0 },
|
||||
destroy() {},
|
||||
} as unknown as EventBus
|
||||
}
|
||||
|
||||
describe('SessionHandle', () => {
|
||||
test('constructor sets properties', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
permissionMode: 'default',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
expect(handle.sessionId).toBe('test-id')
|
||||
expect(handle.cwd).toBe('/tmp')
|
||||
expect(handle.model).toBe('claude-sonnet-4-20250514')
|
||||
expect(handle.permissionMode).toBe('default')
|
||||
expect(handle.status).toBe('starting')
|
||||
expect(handle.prompting).toBe(false)
|
||||
})
|
||||
|
||||
test('getInfo returns correct shape', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
const info = handle.getInfo()
|
||||
expect(info.session_id).toBe('test-id')
|
||||
expect(info.status).toBe('starting')
|
||||
expect(info.cwd).toBe('/tmp')
|
||||
expect(typeof info.created_at).toBe('number')
|
||||
expect(typeof info.last_active_at).toBe('number')
|
||||
})
|
||||
|
||||
test('setTitle updates title', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
handle.setTitle('My Session')
|
||||
expect(handle.title).toBe('My Session')
|
||||
})
|
||||
|
||||
test('onMessage returns unsubscribe function', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
const received: unknown[] = []
|
||||
const unsub = handle.onMessage(msg => received.push(msg))
|
||||
expect(typeof unsub).toBe('function')
|
||||
unsub()
|
||||
})
|
||||
|
||||
test('prompt throws if not running', async () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
expect(handle.prompt('hello')).rejects.toThrow('not running')
|
||||
})
|
||||
|
||||
test('getPendingPermissions returns empty initially', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
expect(handle.getPendingPermissions()).toEqual([])
|
||||
})
|
||||
|
||||
test('getPendingQuestions returns empty initially', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
expect(handle.getPendingQuestions()).toEqual([])
|
||||
})
|
||||
|
||||
test('kill sets status to stopped', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
handle.kill()
|
||||
expect(handle.status).toBe('stopped')
|
||||
})
|
||||
|
||||
test('usage returns token counts', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'test-id',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
const usage = handle.usage
|
||||
expect(usage).toEqual({ input_tokens: 0, output_tokens: 0 })
|
||||
})
|
||||
})
|
||||
60
src/server/__tests__/sessionManager.test.ts
Normal file
60
src/server/__tests__/sessionManager.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { SessionManager } from '../sessionManager.js'
|
||||
import { EventBus } from '../eventBus.js'
|
||||
|
||||
describe('SessionManager', () => {
|
||||
test('constructor sets defaults', () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.getActiveCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('getSession returns undefined for unknown id', () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.getSession('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('getAllSessions returns empty initially', () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.getAllSessions()).toEqual([])
|
||||
})
|
||||
|
||||
test('getSessionStatuses returns empty object', () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.getSessionStatuses()).toEqual({})
|
||||
})
|
||||
|
||||
test('getAllPendingPermissions returns empty', () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.getAllPendingPermissions()).toEqual([])
|
||||
})
|
||||
|
||||
test('getAllPendingQuestions returns empty', () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.getAllPendingQuestions()).toEqual([])
|
||||
})
|
||||
|
||||
test('findPermissionAcrossSessions returns null for unknown', () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.findPermissionAcrossSessions('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
test('findQuestionAcrossSessions returns null for unknown', () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.findQuestionAcrossSessions('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
test('deleteSession returns false for unknown id', async () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
const result = await mgr.deleteSession('nonexistent')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
38
src/server/__tests__/transcriptReader.test.ts
Normal file
38
src/server/__tests__/transcriptReader.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, test, beforeEach } from 'bun:test'
|
||||
import { readSessionMessages, readSessionTodos, clearPathCache } from '../transcriptReader.js'
|
||||
|
||||
describe('transcriptReader', () => {
|
||||
beforeEach(() => {
|
||||
clearPathCache()
|
||||
})
|
||||
|
||||
test('readSessionMessages returns empty for non-existent session', async () => {
|
||||
const result = await readSessionMessages({
|
||||
sessionId: 'nonexistent-session-id-12345',
|
||||
})
|
||||
expect(result.messages).toEqual([])
|
||||
})
|
||||
|
||||
test('readSessionTodos returns empty for non-existent session', async () => {
|
||||
const result = await readSessionTodos({
|
||||
sessionId: 'nonexistent-session-id-12345',
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('readSessionMessages respects limit', async () => {
|
||||
const result = await readSessionMessages({
|
||||
sessionId: 'nonexistent-session-id-12345',
|
||||
limit: 10,
|
||||
})
|
||||
expect(result.messages).toEqual([])
|
||||
})
|
||||
|
||||
test('readSessionMessages with includeSystem flag', async () => {
|
||||
const result = await readSessionMessages({
|
||||
sessionId: 'nonexistent-session-id-12345',
|
||||
includeSystem: true,
|
||||
})
|
||||
expect(result.messages).toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user