diff --git a/test/operation-lightspeed.test.ts b/test/operation-lightspeed.test.ts new file mode 100644 index 00000000..dc9c6058 --- /dev/null +++ b/test/operation-lightspeed.test.ts @@ -0,0 +1,1324 @@ +/** + * @fileoverview Tests for Operation Lightspeed performance optimizations. + * + * Covers: + * - SSE subscription filter edge cases (empty params, whitespace, duplicates) + * - extractSessionId logic (sessionId vs id field, global events) + * - Tab switching: terminal buffer loading, session creation + switch + * - Terminal data cap / backpressure recovery + * - Lazy teammate terminal lifecycle + * - SSE padding strategy (only latency-sensitive events) + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { WebServer } from '../src/web/server.js'; + +const TEST_PORT = 3215; + +// Helper to parse SSE events from raw text +function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> { + const events: Array<{ event: string; data: unknown }> = []; + const lines = text.split('\n'); + let currentEvent = ''; + let currentData = ''; + + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.substring(7); + } else if (line.startsWith('data: ')) { + currentData = line.substring(6); + } else if (line === '') { + if (currentEvent && currentData) { + try { + events.push({ event: currentEvent, data: JSON.parse(currentData) }); + } catch { + events.push({ event: currentEvent, data: currentData }); + } + } + currentEvent = ''; + currentData = ''; + } + } + + return events; +} + +// Helper to collect SSE events for a given duration +async function collectSSEEvents( + baseUrl: string, + queryParams: string, + durationMs: number +): Promise> { + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events${queryParams}`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* AbortError expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, durationMs)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* AbortError expected */ + } + + return parseSSEEvents(receivedData); +} + +// Helper to create a session and return its ID +async function createSession(baseUrl: string): Promise { + const res = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: '/tmp' }), + }); + const data = await res.json(); + if (!data.session?.id) { + throw new Error(`Failed to create session: ${JSON.stringify(data)}`); + } + return data.session.id; +} + +// Helper to delete a session +async function deleteSession(baseUrl: string, id: string): Promise { + await fetch(`${baseUrl}/api/sessions/${id}`, { method: 'DELETE' }); +} + +describe('Operation Lightspeed', () => { + let server: WebServer; + let baseUrl: string; + + beforeAll(async () => { + server = new WebServer(TEST_PORT, false, true); + await server.start(); + baseUrl = `http://localhost:${TEST_PORT}`; + }); + + afterAll(async () => { + await server.stop(); + }, 60000); + + // ═══════════════════════════════════════════════════════════════ + // SSE Subscription Filter — Edge Cases + // ═══════════════════════════════════════════════════════════════ + + describe('SSE Subscription Filter Edge Cases', () => { + it('should treat empty sessions param as no filter (receive all)', async () => { + // ?sessions= (empty string) should not create a filter + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Create a session — should arrive since empty filter = no filter + const sessionId = await createSession(baseUrl); + + await new Promise((resolve) => setTimeout(resolve, 300)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + const created = events.find((e) => e.event === 'session:created'); + expect(created).toBeDefined(); + + await deleteSession(baseUrl, sessionId); + }); + + it('should handle whitespace-only session IDs gracefully', async () => { + // ?sessions= , , — should be treated as no valid IDs = no filter + const events = await collectSSEEvents(baseUrl, '?sessions=%20,%20,%20', 400); + + // Should still receive init event + const initEvent = events.find((e) => e.event === 'init'); + expect(initEvent).toBeDefined(); + }); + + it('should deduplicate session IDs in filter', async () => { + // Create a session + const sessionId = await createSession(baseUrl); + + // Subscribe with duplicated ID + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${sessionId},${sessionId},${sessionId}`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Delete session — should emit session:deleted once + await deleteSession(baseUrl, sessionId); + + await new Promise((resolve) => setTimeout(resolve, 300)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + const deleted = events.filter((e) => e.event === 'session:deleted' && (e.data as any).id === sessionId); + // Should receive exactly one session:deleted event (not duplicated) + expect(deleted.length).toBe(1); + }); + + it('should handle sessions param with trailing comma', async () => { + const sessionId = await createSession(baseUrl); + + // Trailing comma: ?sessions=id, — should parse just the one ID + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${sessionId},`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + await deleteSession(baseUrl, sessionId); + await new Promise((resolve) => setTimeout(resolve, 300)); + + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + const deleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === sessionId); + expect(deleted).toBeDefined(); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // extractSessionId — Event Classification + // ═══════════════════════════════════════════════════════════════ + + describe('extractSessionId via SSE Filtering', () => { + it('should route session:updated events by id field', async () => { + // Create two sessions + const session1 = await createSession(baseUrl); + const session2 = await createSession(baseUrl); + + // Subscribe only to session1 + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${session1}`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Rename session2 — emits session:updated with { id: session2 } + await fetch(`${baseUrl}/api/sessions/${session2}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'renamed-session' }), + }); + + // Rename session1 — emits session:updated with { id: session1 } + await fetch(`${baseUrl}/api/sessions/${session1}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'my-session' }), + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + + // Should receive session:updated for session1 only + const updatedEvents = events.filter((e) => e.event === 'session:updated'); + const session1Updated = updatedEvents.find((e) => (e.data as any).id === session1); + const session2Updated = updatedEvents.find((e) => (e.data as any).id === session2); + + expect(session1Updated).toBeDefined(); + expect(session2Updated).toBeUndefined(); + + // Cleanup + await deleteSession(baseUrl, session1); + await deleteSession(baseUrl, session2); + }); + + it('should filter session:deleted by session ID (sessionId extraction from id field)', async () => { + // Tests extractSessionId's fallback path: session:* events use `id` not `sessionId` + const target = await createSession(baseUrl); + const other = await createSession(baseUrl); + + // Subscribe only to target + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${target}`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Delete the other session — should NOT appear for our filtered client + await deleteSession(baseUrl, other); + + // Delete target — SHOULD appear + await deleteSession(baseUrl, target); + + await new Promise((resolve) => setTimeout(resolve, 300)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + + // Target deletion should arrive (extractSessionId matches `id` field for session:* events) + const targetDeleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === target); + expect(targetDeleted).toBeDefined(); + + // Other deletion should NOT arrive + const otherDeleted = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === other); + expect(otherDeleted).toBeUndefined(); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Tab Switching — Terminal Buffer & Session Management + // ═══════════════════════════════════════════════════════════════ + + describe('Tab Switching — Terminal Buffer Loading', () => { + it('should return terminal buffer for a session', async () => { + const sessionId = await createSession(baseUrl); + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal`); + const data = await res.json(); + + expect(res.status).toBe(200); + // terminalBuffer may be empty for a fresh session, but field should exist + expect(data).toHaveProperty('terminalBuffer'); + expect(data).toHaveProperty('truncated'); + expect(data.truncated).toBe(false); + + await deleteSession(baseUrl, sessionId); + }); + + it('should support tail parameter for terminal buffer', async () => { + const sessionId = await createSession(baseUrl); + + // Fetch with tail=1024 — should not crash even if buffer is smaller + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal?tail=1024`); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data).toHaveProperty('terminalBuffer'); + + await deleteSession(baseUrl, sessionId); + }); + + it('should return error for terminal of nonexistent session', async () => { + const res = await fetch(`${baseUrl}/api/sessions/nonexistent-id/terminal`); + const data = await res.json(); + // Server returns 200 with error body (createErrorResponse convention) + expect(data.success).toBe(false); + expect(data.errorCode).toBeDefined(); + }); + + it('should handle rapid tab switches (multiple sessions created)', async () => { + // Create 3 sessions rapidly + const ids = await Promise.all([createSession(baseUrl), createSession(baseUrl), createSession(baseUrl)]); + + // Fetch all terminal buffers in parallel (simulates rapid tab switching) + const results = await Promise.all( + ids.map((id) => fetch(`${baseUrl}/api/sessions/${id}/terminal?tail=131072`).then((r) => r.json())) + ); + + // All should succeed + for (const data of results) { + expect(data).toHaveProperty('terminalBuffer'); + } + + // Cleanup + await Promise.all(ids.map((id) => deleteSession(baseUrl, id))); + }); + + it('should handle concurrent tab switch + session deletion gracefully', async () => { + const sessionId = await createSession(baseUrl); + + // Start fetching terminal buffer and delete session concurrently + const [terminalRes, deleteRes] = await Promise.all([ + fetch(`${baseUrl}/api/sessions/${sessionId}/terminal`), + // Small delay before delete so the terminal fetch likely starts first + new Promise((resolve) => + setTimeout(async () => { + resolve(await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })); + }, 10) + ), + ]); + + // Terminal fetch may succeed (200) or fail (404) depending on timing — both are valid + expect([200, 404]).toContain(terminalRes.status); + expect(deleteRes.status).toBe(200); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // SSE Client Lifecycle & Cleanup + // ═══════════════════════════════════════════════════════════════ + + describe('SSE Client Lifecycle', () => { + it('should clean up SSE client on disconnect', async () => { + // Connect and immediately disconnect + const controller = new AbortController(); + + const fetchPromise = fetch(`${baseUrl}/api/events`, { + signal: controller.signal, + }).then(async (response) => { + // Read just the first chunk + const reader = response.body?.getReader(); + if (reader) { + await reader.read(); + reader.cancel(); + } + }); + + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + // Server should clean up — verify by connecting again (should work) + const events = await collectSSEEvents(baseUrl, '', 300); + expect(events.find((e) => e.event === 'init')).toBeDefined(); + }); + + it('should handle multiple SSE clients with different filters', async () => { + const session1 = await createSession(baseUrl); + const session2 = await createSession(baseUrl); + + // Client A: subscribes to session1 + // Client B: subscribes to session2 + // Client C: no filter (all events) + const controllerA = new AbortController(); + const controllerB = new AbortController(); + const controllerC = new AbortController(); + let dataA = ''; + let dataB = ''; + let dataC = ''; + + const fetchA = fetch(`${baseUrl}/api/events?sessions=${session1}`, { + signal: controllerA.signal, + }).then(async (r) => { + const reader = r.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + dataA += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + const fetchB = fetch(`${baseUrl}/api/events?sessions=${session2}`, { + signal: controllerB.signal, + }).then(async (r) => { + const reader = r.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + dataB += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + const fetchC = fetch(`${baseUrl}/api/events`, { + signal: controllerC.signal, + }).then(async (r) => { + const reader = r.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + dataC += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Delete session1 — A and C should see it, B should not + await deleteSession(baseUrl, session1); + // Delete session2 — B and C should see it, A should not + await deleteSession(baseUrl, session2); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + controllerA.abort(); + controllerB.abort(); + controllerC.abort(); + try { + await fetchA; + } catch { + /* expected */ + } + try { + await fetchB; + } catch { + /* expected */ + } + try { + await fetchC; + } catch { + /* expected */ + } + + const eventsA = parseSSEEvents(dataA); + const eventsB = parseSSEEvents(dataB); + const eventsC = parseSSEEvents(dataC); + + // Client A: sees session1 deleted, not session2 + expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined(); + expect(eventsA.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeUndefined(); + + // Client B: sees session2 deleted, not session1 + expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined(); + expect(eventsB.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeUndefined(); + + // Client C: sees both + expect(eventsC.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1)).toBeDefined(); + expect(eventsC.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2)).toBeDefined(); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Session Name / Rename (tab label changes) + // ═══════════════════════════════════════════════════════════════ + + describe('Session Rename (Tab Labels)', () => { + it('should rename a session and emit session:updated', async () => { + const sessionId = await createSession(baseUrl); + + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const renameRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Lightspeed Test' }), + }); + expect(renameRes.status).toBe(200); + + await new Promise((resolve) => setTimeout(resolve, 300)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + const updated = events.find((e) => e.event === 'session:updated' && (e.data as any).id === sessionId); + expect(updated).toBeDefined(); + expect((updated?.data as any).name).toBe('Lightspeed Test'); + + await deleteSession(baseUrl, sessionId); + }); + + it('should accept empty session name (schema allows min 0)', async () => { + const sessionId = await createSession(baseUrl); + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '' }), + }); + const data = await res.json(); + expect(data.success).toBe(true); + + await deleteSession(baseUrl, sessionId); + }); + + it('should reject overly long session name via schema', async () => { + const sessionId = await createSession(baseUrl); + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'A'.repeat(200) }), + }); + const data = await res.json(); + // Schema max(128) rejects 200 chars — returns success: false in body + expect(data.success).toBe(false); + expect(data.errorCode).toBeDefined(); + + await deleteSession(baseUrl, sessionId); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Session List / Status (Tab Bar Data) + // ═══════════════════════════════════════════════════════════════ + + describe('Session List for Tab Bar', () => { + it('should return session list without terminal buffers (light state)', async () => { + const sessionId = await createSession(baseUrl); + + const res = await fetch(`${baseUrl}/api/sessions`); + const sessions = await res.json(); + + expect(Array.isArray(sessions)).toBe(true); + const session = sessions.find((s: any) => s.id === sessionId); + expect(session).toBeDefined(); + + // Light state should NOT include full terminal buffer + // (the terminalBuffer field should be empty or minimal, not megabytes) + if (session.terminalBuffer) { + // If included at all, it should be reasonable (not the full 2MB) + expect(session.terminalBuffer.length).toBeLessThan(1024 * 1024); + } + + await deleteSession(baseUrl, sessionId); + }); + + it('should include all created sessions in list', async () => { + // Create sessions sequentially to avoid race conditions + const id1 = await createSession(baseUrl); + const id2 = await createSession(baseUrl); + const id3 = await createSession(baseUrl); + + // Wait for cache invalidation (SESSIONS_LIST_CACHE_TTL = 1s) + await new Promise((resolve) => setTimeout(resolve, 1100)); + + const res = await fetch(`${baseUrl}/api/sessions`); + const sessions = await res.json(); + + // All 3 should be present in the response + const foundIds = sessions.map((s: any) => s.id); + expect(foundIds).toContain(id1); + expect(foundIds).toContain(id2); + expect(foundIds).toContain(id3); + + await Promise.all([id1, id2, id3].map((id) => deleteSession(baseUrl, id))); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // SSE Padding Strategy + // ═══════════════════════════════════════════════════════════════ + + describe('SSE Padding Strategy', () => { + it('should not include tunnel padding on session:created events (low frequency)', async () => { + // When tunnel is NOT active, no events should have padding + const controller = new AbortController(); + let rawData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + rawData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + const sessionId = await createSession(baseUrl); + await new Promise((resolve) => setTimeout(resolve, 300)); + + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + // SSE_PADDING is 1KB+ of spaces. session:created should not have it + // when tunnel is not active. Check that data between events is clean. + // Find "session:created" in raw data — check there's no massive padding after it + const createdIdx = rawData.indexOf('event: session:created'); + expect(createdIdx).toBeGreaterThan(-1); + + // Find next event after session:created + const afterCreated = rawData.substring(createdIdx); + const dataEnd = afterCreated.indexOf('\n\n'); + if (dataEnd > -1) { + const between = afterCreated.substring(dataEnd + 2); + const nextEvent = between.indexOf('event: '); + if (nextEvent > 0) { + // Gap between events should be small (no 1KB padding) + expect(nextEvent).toBeLessThan(100); + } + } + + await deleteSession(baseUrl, sessionId); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Tab Switching — SSE Filter Update Simulation + // ═══════════════════════════════════════════════════════════════ + + describe('Tab Switching — SSE Filter Simulation', () => { + it('should allow reconnecting SSE with different session filter (tab switch)', async () => { + // Simulates a tab switch: client disconnects SSE for session1, reconnects for session2 + const session1 = await createSession(baseUrl); + const session2 = await createSession(baseUrl); + + // Phase 1: Subscribe to session1 + const controller1 = new AbortController(); + let data1 = ''; + + const fetch1 = fetch(`${baseUrl}/api/events?sessions=${session1}`, { + signal: controller1.signal, + }).then(async (r) => { + const reader = r.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + data1 += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Disconnect (simulates tab switch away from session1) + controller1.abort(); + try { + await fetch1; + } catch { + /* expected */ + } + + // Phase 2: Reconnect subscribing to session2 (tab switch to session2) + const controller2 = new AbortController(); + let data2 = ''; + + const fetch2 = fetch(`${baseUrl}/api/events?sessions=${session2}`, { + signal: controller2.signal, + }).then(async (r) => { + const reader = r.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + data2 += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Delete session2 — should arrive in new subscription + await deleteSession(baseUrl, session2); + await new Promise((resolve) => setTimeout(resolve, 300)); + + controller2.abort(); + try { + await fetch2; + } catch { + /* expected */ + } + + const events2 = parseSSEEvents(data2); + const deleted = events2.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2); + expect(deleted).toBeDefined(); + + // Phase 1 data should have init but no session2 events + const events1 = parseSSEEvents(data1); + const session2InPhase1 = events1.find((e) => (e.data as any)?.id === session2 && e.event !== 'init'); + expect(session2InPhase1).toBeUndefined(); + + await deleteSession(baseUrl, session1); + }); + + it('should receive init state with all sessions on each SSE reconnect', async () => { + const session1 = await createSession(baseUrl); + const session2 = await createSession(baseUrl); + + // Connect with filter for session1 + const events = await collectSSEEvents(baseUrl, `?sessions=${session1}`, 400); + + const initEvent = events.find((e) => e.event === 'init'); + expect(initEvent).toBeDefined(); + + // Init should contain ALL sessions (not filtered) — client needs full list for tab bar + const initSessions = (initEvent?.data as any).sessions; + expect(Array.isArray(initSessions)).toBe(true); + const initIds = initSessions.map((s: any) => s.id); + expect(initIds).toContain(session1); + expect(initIds).toContain(session2); + + await Promise.all([deleteSession(baseUrl, session1), deleteSession(baseUrl, session2)]); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Terminal Buffer — Local Echo Support + // ═══════════════════════════════════════════════════════════════ + + describe('Terminal Buffer — Local Echo Support', () => { + it('should return terminal buffer with status field for local echo state sync', async () => { + const sessionId = await createSession(baseUrl); + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal`); + const data = await res.json(); + + expect(res.status).toBe(200); + // Local echo overlay needs session status to know when to show/hide + expect(data).toHaveProperty('status'); + expect(typeof data.status).toBe('string'); + // Fresh session starts as 'starting' + expect(['starting', 'running', 'idle', 'error']).toContain(data.status); + + await deleteSession(baseUrl, sessionId); + }); + + it('should return fullSize field for buffer truncation detection', async () => { + const sessionId = await createSession(baseUrl); + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal?tail=100`); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data).toHaveProperty('fullSize'); + expect(typeof data.fullSize).toBe('number'); + expect(data.fullSize).toBeGreaterThanOrEqual(0); + + await deleteSession(baseUrl, sessionId); + }); + + it('should handle tail=0 same as no tail (full buffer)', async () => { + const sessionId = await createSession(baseUrl); + + const [fullRes, tailZeroRes] = await Promise.all([ + fetch(`${baseUrl}/api/sessions/${sessionId}/terminal`).then((r) => r.json()), + fetch(`${baseUrl}/api/sessions/${sessionId}/terminal?tail=0`).then((r) => r.json()), + ]); + + // tail=0 means "don't tail" — should return same as no tail param + expect(fullRes.truncated).toBe(false); + expect(tailZeroRes.truncated).toBe(false); + expect(fullRes.terminalBuffer).toBe(tailZeroRes.terminalBuffer); + + await deleteSession(baseUrl, sessionId); + }); + + it('should handle very large tail value gracefully', async () => { + const sessionId = await createSession(baseUrl); + + // Tail larger than buffer — should return full buffer without error + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal?tail=999999999`); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data).toHaveProperty('terminalBuffer'); + expect(data.truncated).toBe(false); // Can't truncate if tail > fullSize + + await deleteSession(baseUrl, sessionId); + }); + + it('should handle negative tail value without crashing', async () => { + const sessionId = await createSession(baseUrl); + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal?tail=-1`); + const data = await res.json(); + + // Should handle gracefully (either return full buffer or error cleanly) + expect(res.status).toBe(200); + expect(data).toHaveProperty('terminalBuffer'); + + await deleteSession(baseUrl, sessionId); + }); + + it('should handle non-numeric tail value without crashing', async () => { + const sessionId = await createSession(baseUrl); + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/terminal?tail=abc`); + const data = await res.json(); + + // NaN tail should be handled (parseInt('abc') = NaN, which is falsy) + expect(res.status).toBe(200); + expect(data).toHaveProperty('terminalBuffer'); + + await deleteSession(baseUrl, sessionId); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // extractSessionId — Additional Edge Cases + // ═══════════════════════════════════════════════════════════════ + + describe('extractSessionId — Edge Cases via SSE', () => { + it('should treat non-session: events with id field as global (not filtered)', async () => { + // Events like case:created have an `id` field but aren't session:* events. + // extractSessionId should NOT use the `id` field for non-session:* events. + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=nonexistent`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Create a case — case:created has an `id` field but is NOT session-scoped + const caseName = `test-extract-${Date.now()}`; + await fetch(`${baseUrl}/api/cases`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: caseName }), + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + // case:created should still arrive (it's global, not filtered by session) + const caseEvent = events.find((e) => e.event === 'case:created'); + expect(caseEvent).toBeDefined(); + + // Cleanup + const { rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { homedir } = await import('node:os'); + try { + rmSync(join(homedir(), 'codeman-cases', caseName), { recursive: true }); + } catch { + /* may not exist */ + } + }); + + it('should deliver session:created for a newly created session to unfiltered client but not mismatched filter', async () => { + // session:created uses `id` field and starts with `session:` — extractSessionId should match it + const existing = await createSession(baseUrl); + + // Subscribe to existing session only + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${existing}`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Create a NEW session — its session:created event should NOT reach our filtered client + const newSession = await createSession(baseUrl); + + await new Promise((resolve) => setTimeout(resolve, 300)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + // session:created for newSession should be filtered OUT (id doesn't match our filter) + const createdEvent = events.find((e) => e.event === 'session:created' && (e.data as any).id === newSession); + expect(createdEvent).toBeUndefined(); + + await Promise.all([deleteSession(baseUrl, existing), deleteSession(baseUrl, newSession)]); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Tab Switching — Session Auto-Select After Delete + // ═══════════════════════════════════════════════════════════════ + + describe('Tab Switching — Session Lifecycle', () => { + it('should emit session:deleted when active tab session is killed', async () => { + const sessionId = await createSession(baseUrl); + + // Subscribe to that session's events (simulates having it as active tab) + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${sessionId}`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + await deleteSession(baseUrl, sessionId); + await new Promise((resolve) => setTimeout(resolve, 300)); + + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + const deleted = events.find((e) => e.event === 'session:deleted'); + expect(deleted).toBeDefined(); + expect((deleted?.data as any).id).toBe(sessionId); + }); + + it('should create and delete multiple sessions rapidly (tab churn)', async () => { + // Rapid create-delete cycles simulating user rapidly opening/closing tabs + const results: boolean[] = []; + + for (let i = 0; i < 5; i++) { + const id = await createSession(baseUrl); + const delRes = await fetch(`${baseUrl}/api/sessions/${id}`, { method: 'DELETE' }); + results.push(delRes.status === 200); + } + + expect(results.every(Boolean)).toBe(true); + }); + + it('should handle delete of already-deleted session gracefully', async () => { + const sessionId = await createSession(baseUrl); + await deleteSession(baseUrl, sessionId); + + // Double delete — should not crash + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }); + const data = await res.json(); + + // Should return error response but not crash the server + expect(data.success).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // SSE Max Clients Limit + // ═══════════════════════════════════════════════════════════════ + + describe('SSE Max Clients', () => { + it('should reject SSE connections when at limit', async () => { + // The MAX_SSE_CLIENTS is 100 — we can't test hitting the exact limit, + // but we can verify the server responds with 503 structure by checking + // that many concurrent connections don't crash + const controllers: AbortController[] = []; + const connections: Promise[] = []; + + // Open 10 concurrent SSE connections + for (let i = 0; i < 10; i++) { + const controller = new AbortController(); + controllers.push(controller); + connections.push( + fetch(`${baseUrl}/api/events`, { signal: controller.signal }) + .then(async (r) => { + expect(r.headers.get('content-type')).toBe('text/event-stream'); + const reader = r.body?.getReader(); + if (reader) { + try { + await reader.read(); + } catch { + /* expected */ + } + } + }) + .catch(() => { + /* AbortError expected */ + }) + ); + } + + await new Promise((resolve) => setTimeout(resolve, 300)); + + // All should be connected — verify server still works + const statusRes = await fetch(`${baseUrl}/api/status`); + expect(statusRes.status).toBe(200); + + // Cleanup all SSE connections + controllers.forEach((c) => c.abort()); + await Promise.allSettled(connections); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Concurrent Operations (stress tests for tab switching) + // ═══════════════════════════════════════════════════════════════ + + describe('Concurrent Operations', () => { + it('should handle 5 concurrent session creates without error', async () => { + const results = await Promise.all( + Array.from({ length: 5 }, () => + fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: '/tmp' }), + }).then((r) => r.json()) + ) + ); + + const ids = results.map((r) => r.session.id); + expect(ids.length).toBe(5); + expect(new Set(ids).size).toBe(5); // All unique + + // Cleanup + await Promise.all(ids.map((id) => deleteSession(baseUrl, id))); + }); + + it('should handle concurrent terminal fetches for different sessions', async () => { + const ids = await Promise.all(Array.from({ length: 3 }, () => createSession(baseUrl))); + + // Fetch all terminal buffers concurrently with tail parameter + const responses = await Promise.all(ids.map((id) => fetch(`${baseUrl}/api/sessions/${id}/terminal?tail=131072`))); + + for (const res of responses) { + expect(res.status).toBe(200); + } + + await Promise.all(ids.map((id) => deleteSession(baseUrl, id))); + }); + + it('should correctly filter SSE under concurrent session lifecycle', async () => { + // Create 2 sessions + const target = await createSession(baseUrl); + const other = await createSession(baseUrl); + + // Subscribe only to target + const controller = new AbortController(); + let receivedData = ''; + + const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${target}`, { + signal: controller.signal, + }).then(async (response) => { + const reader = response.body?.getReader(); + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedData += new TextDecoder().decode(value); + } + } catch { + /* expected */ + } + } + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Rapid operations on both sessions concurrently + await Promise.all([ + fetch(`${baseUrl}/api/sessions/${target}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'target-renamed' }), + }), + fetch(`${baseUrl}/api/sessions/${other}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'other-renamed' }), + }), + deleteSession(baseUrl, other), + ]); + + await new Promise((resolve) => setTimeout(resolve, 300)); + controller.abort(); + try { + await fetchPromise; + } catch { + /* expected */ + } + + const events = parseSSEEvents(receivedData); + + // Should see target's rename but not other's events + const targetUpdated = events.find((e) => e.event === 'session:updated' && (e.data as any).id === target); + expect(targetUpdated).toBeDefined(); + + // Should NOT see other's events + const otherEvents = events.filter( + (e) => ((e.data as any)?.id === other || (e.data as any)?.sessionId === other) && e.event !== 'init' + ); + expect(otherEvents.length).toBe(0); + + await deleteSession(baseUrl, target); + }); + }); +});