/** * Happy Path Test: Session Cleanup (SessionEnd) * * Tests that when a session ends, the worker marks it complete * and performs necessary cleanup operations. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { sessionScenario } from '../helpers/scenarios.js'; import { getWorkerPort } from '../../src/shared/worker-utils.js'; describe('Session Cleanup (SessionEnd)', () => { const WORKER_PORT = getWorkerPort(); beforeEach(() => { vi.clearAllMocks(); }); it('marks session complete and stops SDK agent', async () => { // This tests the happy path: // Session ends → Hook notifies worker → Worker marks session complete → // SDK agent stopped → Resources cleaned up // Setup: Mock successful response from worker global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 'completed' }) }); // Execute: Send complete request (what cleanup-hook does) const response = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionScenario.claudeSessionId, reason: 'user_exit' }) } ); // Verify: Session marked complete expect(response.ok).toBe(true); const result = await response.json(); expect(result.status).toBe('completed'); // Verify: Correct data sent to worker const fetchCall = (global.fetch as any).mock.calls[0]; const requestBody = JSON.parse(fetchCall[1].body); expect(requestBody.claudeSessionId).toBe(sessionScenario.claudeSessionId); expect(requestBody.reason).toBe('user_exit'); }); it('handles missing session ID gracefully', async () => { // Setup: Mock error response global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: 'Missing claudeSessionId' }) }); // Execute: Send complete request without session ID const response = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'user_exit' }) } ); // Verify: Returns error expect(response.ok).toBe(false); expect(response.status).toBe(400); const error = await response.json(); expect(error.error).toContain('Missing claudeSessionId'); }); it('handles different session end reasons', async () => { // Setup: Track all cleanup requests const cleanupRequests: any[] = []; global.fetch = vi.fn().mockImplementation(async (url, options) => { const body = JSON.parse(options.body); cleanupRequests.push(body); return { ok: true, status: 200, json: async () => ({ status: 'completed' }) }; }); // Test different end reasons const reasons = [ 'user_exit', // User explicitly ended session 'timeout', // Session timed out 'error', // Error occurred 'restart', // Session restarting 'clear' // User cleared context ]; // Execute: Send cleanup for each reason for (const reason of reasons) { await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: `session-${reason}`, reason }) } ); } // Verify: All cleanup requests processed expect(cleanupRequests.length).toBe(5); expect(cleanupRequests.map(r => r.reason)).toEqual(reasons); }); it('completes multiple sessions independently', async () => { // Setup: Track session completions const completedSessions: string[] = []; global.fetch = vi.fn().mockImplementation(async (url, options) => { const body = JSON.parse(options.body); completedSessions.push(body.claudeSessionId); return { ok: true, status: 200, json: async () => ({ status: 'completed' }) }; }); const sessions = [ 'session-abc-123', 'session-def-456', 'session-ghi-789' ]; // Execute: Complete multiple sessions for (const sessionId of sessions) { await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionId, reason: 'user_exit' }) } ); } // Verify: All sessions completed expect(completedSessions).toEqual(sessions); }); it('handles cleanup when session not found', async () => { // Setup: Mock 404 response for non-existent session global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({ error: 'Session not found' }) }); // Execute: Try to complete non-existent session const response = await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: 'non-existent-session', reason: 'user_exit' }) } ); // Verify: Returns 404 (graceful handling) expect(response.ok).toBe(false); expect(response.status).toBe(404); }); it('supports optional metadata in cleanup request', async () => { // Setup: Mock worker response global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 'completed' }) }); // Execute: Send cleanup with additional metadata await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionScenario.claudeSessionId, reason: 'user_exit', duration_seconds: 1800, observations_count: 25, project: 'claude-mem' }) } ); // Verify: Metadata included in request const fetchCall = (global.fetch as any).mock.calls[0]; const requestBody = JSON.parse(fetchCall[1].body); expect(requestBody.duration_seconds).toBe(1800); expect(requestBody.observations_count).toBe(25); expect(requestBody.project).toBe('claude-mem'); }); it('handles worker being down during cleanup', async () => { // Setup: Mock worker unreachable global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); // Execute: Attempt to complete session try { await fetch( `http://127.0.0.1:${WORKER_PORT}/api/sessions/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claudeSessionId: sessionScenario.claudeSessionId, reason: 'user_exit' }) } ); // Should throw, so fail if we get here expect(true).toBe(false); } catch (error: any) { // Verify: Error indicates worker is down expect(error.message).toContain('ECONNREFUSED'); } // The hook should log this but not fail the session end // (This graceful degradation would be tested in hook-specific tests) }); });