import { describe, test, expect, beforeEach, mock } from 'bun:test' import { mockConfigModule } from './helpers/mock-config' // Mock config before importing modules mock.module('../config', () => mockConfigModule()) import { storeReset, storeCreateEnvironment } from '../store' import { createSession, createCodeSession, getSession, updateSessionTitle, updateSessionStatus, archiveSession, incrementEpoch, listSessions, listSessionSummaries, listSessionSummariesByUsername, listSessionsByEnvironment, } from '../services/session' import { registerEnvironment, deregisterEnvironment, getEnvironment, updatePollTime, listActiveEnvironments, listActiveEnvironmentsResponse, listActiveEnvironmentsByUsername, reconnectEnvironment, } from '../services/environment' import { normalizePayload, publishSessionEvent } from '../services/transport' import { getEventBus, removeEventBus, getAllEventBuses, } from '../transport/event-bus' // ---------- Session Service ---------- describe('Session Service', () => { beforeEach(() => { storeReset() for (const [key] of getAllEventBuses()) { removeEventBus(key) } }) describe('createSession', () => { test('creates a session with defaults', () => { const resp = createSession({}) expect(resp.id).toMatch(/^session_/) expect(resp.status).toBe('idle') expect(resp.source).toBe('remote-control') expect(resp.environment_id).toBeNull() expect(resp.worker_epoch).toBe(0) expect(resp.created_at).toBeGreaterThan(0) }) test('creates a session with all options', () => { const env = storeCreateEnvironment({ secret: 's' }) const resp = createSession({ environment_id: env.id, title: 'My Session', source: 'cli', permission_mode: 'auto', }) expect(resp.environment_id).toBe(env.id) expect(resp.title).toBe('My Session') expect(resp.source).toBe('cli') expect(resp.permission_mode).toBe('auto') }) test('creates session with username', () => { const resp = createSession({ username: 'alice' }) expect(resp.username).toBe('alice') }) }) describe('createCodeSession', () => { test('creates a code session with cse_ prefix', () => { const resp = createCodeSession({}) expect(resp.id).toMatch(/^cse_/) }) }) describe('getSession', () => { test('returns null for non-existent session', () => { expect(getSession('nope')).toBeNull() }) test('returns created session', () => { const created = createSession({}) const fetched = getSession(created.id) expect(fetched).not.toBeNull() expect(fetched!.id).toBe(created.id) }) }) describe('updateSessionTitle', () => { test('updates title', () => { const s = createSession({}) updateSessionTitle(s.id, 'New Title') expect(getSession(s.id)?.title).toBe('New Title') }) }) describe('updateSessionStatus', () => { test('updates status', () => { const s = createSession({}) updateSessionStatus(s.id, 'active') expect(getSession(s.id)?.status).toBe('active') }) }) describe('archiveSession', () => { test('sets status to archived and removes event bus', () => { const s = createSession({}) // Create event bus for this session getEventBus(s.id) archiveSession(s.id) expect(getSession(s.id)?.status).toBe('archived') expect(getAllEventBuses().has(s.id)).toBe(false) }) }) describe('incrementEpoch', () => { test('increments epoch by 1', () => { const s = createSession({}) expect(incrementEpoch(s.id)).toBe(1) expect(incrementEpoch(s.id)).toBe(2) expect(getSession(s.id)?.worker_epoch).toBe(2) }) test('throws for non-existent session', () => { expect(() => incrementEpoch('nope')).toThrow('Session not found') }) }) describe('listSessions', () => { test('returns all sessions', () => { createSession({}) createSession({}) expect(listSessions()).toHaveLength(2) }) }) describe('listSessionSummaries', () => { test('returns summaries with correct fields', () => { createSession({ title: 'Test' }) const summaries = listSessionSummaries() expect(summaries).toHaveLength(1) expect(summaries[0].title).toBe('Test') expect(summaries[0].updated_at).toBeGreaterThan(0) // Summary includes environment_id so the Web UI can resolve per-env // worker tokens without fetching each session in full. expect('environment_id' in summaries[0]).toBe(true) expect(summaries[0].environment_id).toBeNull() }) }) describe('listSessionSummariesByUsername', () => { test('filters by username', () => { createSession({ username: 'alice' }) createSession({ username: 'bob' }) expect(listSessionSummariesByUsername('alice')).toHaveLength(1) }) }) describe('listSessionsByEnvironment', () => { test('filters by environment', () => { const env = storeCreateEnvironment({ secret: 's' }) createSession({ environment_id: env.id }) createSession({}) expect(listSessionsByEnvironment(env.id)).toHaveLength(1) }) }) }) // ---------- Environment Service ---------- describe('Environment Service', () => { beforeEach(() => { storeReset() }) describe('registerEnvironment', () => { test('registers environment with defaults', () => { const result = registerEnvironment({}) expect(result.environment_id).toMatch(/^env_/) expect(result.environment_secret).toBe('test-api-key') expect(result.status).toBe('active') }) test('registers with options', () => { const result = registerEnvironment({ machine_name: 'mac1', directory: '/home/user', branch: 'main', git_repo_url: 'https://github.com/test/repo', max_sessions: 5, worker_type: 'custom', }) const env = getEnvironment(result.environment_id) expect(env?.machineName).toBe('mac1') expect(env?.directory).toBe('/home/user') expect(env?.maxSessions).toBe(5) }) test('registers with username', () => { const result = registerEnvironment({ username: 'alice' }) const env = getEnvironment(result.environment_id) expect(env?.username).toBe('alice') }) }) describe('deregisterEnvironment', () => { test('sets status to deregistered', () => { const result = registerEnvironment({}) deregisterEnvironment(result.environment_id) const env = getEnvironment(result.environment_id) expect(env?.status).toBe('deregistered') }) }) describe('updatePollTime', () => { test('updates lastPollAt', () => { const result = registerEnvironment({}) const before = getEnvironment(result.environment_id)?.lastPollAt // Small delay to ensure time difference updatePollTime(result.environment_id) const after = getEnvironment(result.environment_id)?.lastPollAt expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime()) }) }) describe('listActiveEnvironments', () => { test('returns active environments', () => { registerEnvironment({}) registerEnvironment({}) expect(listActiveEnvironments()).toHaveLength(2) }) }) describe('listActiveEnvironmentsResponse', () => { test('returns response format', () => { registerEnvironment({ machine_name: 'mac1' }) const envs = listActiveEnvironmentsResponse() expect(envs).toHaveLength(1) expect(envs[0].machine_name).toBe('mac1') expect(envs[0].last_poll_at).toBeGreaterThan(0) }) }) describe('listActiveEnvironmentsByUsername', () => { test('filters by username', () => { registerEnvironment({ username: 'alice' }) registerEnvironment({ username: 'bob' }) expect(listActiveEnvironmentsByUsername('alice')).toHaveLength(1) }) }) describe('reconnectEnvironment', () => { test('sets status back to active', () => { const result = registerEnvironment({}) deregisterEnvironment(result.environment_id) expect(getEnvironment(result.environment_id)?.status).toBe('deregistered') reconnectEnvironment(result.environment_id) expect(getEnvironment(result.environment_id)?.status).toBe('active') }) }) }) // ---------- Transport Service ---------- describe('Transport Service', () => { beforeEach(() => { storeReset() for (const [key] of getAllEventBuses()) { removeEventBus(key) } }) describe('normalizePayload', () => { test('handles string payload', () => { const result = normalizePayload('user', 'hello world') expect(result.content).toBe('hello world') expect(result.raw).toBe('hello world') }) test('handles null payload', () => { const result = normalizePayload('user', null) expect(result.content).toBe('') expect(result.raw).toBeNull() }) test('handles object with direct content', () => { const result = normalizePayload('user', { content: 'direct text' }) expect(result.content).toBe('direct text') }) test('handles object with message.content string', () => { const result = normalizePayload('assistant', { message: { role: 'assistant', content: 'reply' }, }) expect(result.content).toBe('reply') }) test('handles object with message.content array', () => { const result = normalizePayload('assistant', { message: { content: [ { type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }, ], }, }) expect(result.content).toBe('hello world') }) test('preserves tool fields', () => { const result = normalizePayload('tool_use', { tool_name: 'Bash', tool_input: { cmd: 'ls' }, }) expect(result.tool_name).toBe('Bash') expect(result.tool_input).toEqual({ cmd: 'ls' }) }) test('preserves permission fields', () => { const result = normalizePayload('permission', { request_id: 'req_1', approved: true, updated_input: { cmd: 'ls -la' }, }) expect(result.request_id).toBe('req_1') expect(result.approved).toBe(true) expect(result.updated_input).toEqual({ cmd: 'ls -la' }) }) test('preserves message field', () => { const msg = { role: 'user', content: 'hi' } const result = normalizePayload('user', { message: msg }) expect(result.message).toEqual(msg) }) test('preserves uuid field', () => { const result = normalizePayload('user', { uuid: 'msg_123', content: 'hi', }) expect(result.uuid).toBe('msg_123') }) test('preserves isSynthetic field', () => { const result = normalizePayload('user', { content: 'scheduled job: refresh analytics cache', isSynthetic: true, }) expect(result.isSynthetic).toBe(true) }) test('uses name as tool_name fallback', () => { const result = normalizePayload('tool', { name: 'Read' }) expect(result.tool_name).toBe('Read') }) test('uses input as tool_input fallback', () => { const result = normalizePayload('tool', { input: { path: '/tmp' } }) expect(result.tool_input).toEqual({ path: '/tmp' }) }) test('handles empty content array', () => { const result = normalizePayload('assistant', { message: { content: [] }, }) expect(result.content).toBe('') }) test('preserves task_state fields', () => { const result = normalizePayload('task_state', { task_list_id: 'team-alpha', tasks: [{ id: '1', subject: 'Task 1', status: 'pending' }], }) expect(result.task_list_id).toBe('team-alpha') expect(result.tasks).toEqual([ { id: '1', subject: 'Task 1', status: 'pending' }, ]) }) test('preserves status metadata for conversation reset events', () => { const result = normalizePayload('status', { status: 'conversation_cleared', subtype: 'status', message: 'conversation_cleared', }) expect(result.status).toBe('conversation_cleared') expect(result.subtype).toBe('status') expect(result.message).toBe('conversation_cleared') }) test('handles undefined payload', () => { const result = normalizePayload('user', undefined) expect(result.content).toBe('') }) }) describe('publishSessionEvent', () => { test('publishes event to session bus', () => { const event = publishSessionEvent( 's1', 'user', { content: 'hello' }, 'outbound', ) expect(event.type).toBe('user') expect(event.direction).toBe('outbound') expect(event.sessionId).toBe('s1') expect(event.seqNum).toBe(1) }) test('normalizes payload before publishing', () => { const event = publishSessionEvent( 's1', 'assistant', { message: { content: 'reply' } }, 'inbound', ) const payload = event.payload as Record expect(payload.content).toBe('reply') }) }) })