From c55bd43f2ecc68df34246aba3b884317710e7d5b Mon Sep 17 00:00:00 2001 From: DoSun Date: Wed, 6 May 2026 14:32:55 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=20server=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/__tests__/errors.test.ts | 55 +++++++ src/server/__tests__/eventBus.test.ts | 122 +++++++++++++++ src/server/__tests__/sessionHandle.test.ts | 147 ++++++++++++++++++ src/server/__tests__/sessionManager.test.ts | 60 +++++++ src/server/__tests__/transcriptReader.test.ts | 38 +++++ 5 files changed, 422 insertions(+) create mode 100644 src/server/__tests__/errors.test.ts create mode 100644 src/server/__tests__/eventBus.test.ts create mode 100644 src/server/__tests__/sessionHandle.test.ts create mode 100644 src/server/__tests__/sessionManager.test.ts create mode 100644 src/server/__tests__/transcriptReader.test.ts diff --git a/src/server/__tests__/errors.test.ts b/src/server/__tests__/errors.test.ts new file mode 100644 index 000000000..0cdbfa550 --- /dev/null +++ b/src/server/__tests__/errors.test.ts @@ -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') + }) +}) diff --git a/src/server/__tests__/eventBus.test.ts b/src/server/__tests__/eventBus.test.ts new file mode 100644 index 000000000..74c012cb8 --- /dev/null +++ b/src/server/__tests__/eventBus.test.ts @@ -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) + }) +}) diff --git a/src/server/__tests__/sessionHandle.test.ts b/src/server/__tests__/sessionHandle.test.ts new file mode 100644 index 000000000..575a9dfde --- /dev/null +++ b/src/server/__tests__/sessionHandle.test.ts @@ -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 }> = [] + return { + publishSessionEvent(sessionId: string, event: string, data: Record) { + 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 }) + }) +}) diff --git a/src/server/__tests__/sessionManager.test.ts b/src/server/__tests__/sessionManager.test.ts new file mode 100644 index 000000000..898bf1d13 --- /dev/null +++ b/src/server/__tests__/sessionManager.test.ts @@ -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) + }) +}) diff --git a/src/server/__tests__/transcriptReader.test.ts b/src/server/__tests__/transcriptReader.test.ts new file mode 100644 index 000000000..06ae98a15 --- /dev/null +++ b/src/server/__tests__/transcriptReader.test.ts @@ -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([]) + }) +})