Files
Codeman/test/sse-events.test.ts
T
RandalixandClaude Opus 5.5 aa4217b8b1 test: build every in-process WebServer on port 0 (#440)
The files on the guard's legacy list build new WebServer(0, …) and read server.boundPort after start(), as sse-tile-grid-filter does. Files with several servers read each server's own port; quick-start's local helper lost its port parameter so the literal 0 sits where the server is built. Header comments that named a port say "ephemeral". No assertion changed except where it embedded the port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:28:26 +02:00

304 lines
8.9 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { EventEmitter } from 'node:events';
import { safeRmHomeTree } from './mocks/index.js';
import { homedir } from 'node:os';
import { join } from 'node:path';
// Helper to parse SSE events
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;
}
describe('SSE Events', () => {
let server: WebServer;
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
await server.stop();
}, 60000);
describe('GET /api/events', () => {
it('should return text/event-stream content type', async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000);
try {
const response = await fetch(`${baseUrl}/api/events`, {
signal: controller.signal,
});
expect(response.headers.get('content-type')).toBe('text/event-stream');
} catch (err: any) {
// AbortError is expected
if (err.name !== 'AbortError') throw err;
} finally {
clearTimeout(timeout);
}
});
it('should send init event on connection', async () => {
const controller = new AbortController();
// Collect data for a short time
let receivedData = '';
const timeout = setTimeout(() => controller.abort(), 500);
try {
const response = await fetch(`${baseUrl}/api/events`, {
signal: controller.signal,
});
const reader = response.body?.getReader();
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
receivedData += new TextDecoder().decode(value);
}
}
} catch (err: any) {
if (err.name !== 'AbortError') throw err;
} finally {
clearTimeout(timeout);
}
// Parse and check for init event
const events = parseSSEEvents(receivedData);
const initEvent = events.find((e) => e.event === 'init');
expect(initEvent).toBeDefined();
expect((initEvent?.data as any).sessions).toBeDefined();
expect((initEvent?.data as any).scheduledRuns).toBeDefined();
expect((initEvent?.data as any).respawnStatus).toBeDefined();
expect((initEvent?.data as any).timestamp).toBeDefined();
});
it('should receive session:created event when session is created', async () => {
const controller = new AbortController();
let receivedData = '';
// Start listening
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 {}
}
});
// Give time to connect
await new Promise((resolve) => setTimeout(resolve, 100));
// Create a session
await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
// Wait a bit for the event
await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening
controller.abort();
try {
await fetchPromise;
} catch {}
// Parse events
const events = parseSSEEvents(receivedData);
const sessionCreated = events.find((e) => e.event === 'session:created');
expect(sessionCreated).toBeDefined();
expect((sessionCreated?.data as any).id).toBeDefined();
});
});
describe('GET /api/status', () => {
it('should return full state', async () => {
const response = await fetch(`${baseUrl}/api/status`);
const body = await response.json();
expect(body.success).toBe(true);
expect(body.data).toHaveProperty('sessions');
expect(body.data).toHaveProperty('scheduledRuns');
expect(body.data).toHaveProperty('respawnStatus');
expect(body.data).toHaveProperty('timestamp');
});
it('should include active sessions', async () => {
// Create a session first
await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const response = await fetch(`${baseUrl}/api/status`);
const body = await response.json();
expect(body.data.sessions.length).toBeGreaterThan(0);
});
});
});
describe('SSE Event Types', () => {
let server: WebServer;
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
await server.stop();
}, 60000);
describe('Session Events', () => {
it('should emit session:deleted when session is deleted', async () => {
const controller = new AbortController();
let receivedData = '';
// Start listening
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 {}
}
});
// Give time to connect
await new Promise((resolve) => setTimeout(resolve, 100));
// Create a session
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const createData = await createRes.json();
// Delete it
await fetch(`${baseUrl}/api/sessions/${createData.data.session.id}`, {
method: 'DELETE',
});
// Wait for events
await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening
controller.abort();
try {
await fetchPromise;
} catch {}
// Parse events
const events = parseSSEEvents(receivedData);
const sessionDeleted = events.find((e) => e.event === 'session:deleted');
expect(sessionDeleted).toBeDefined();
expect((sessionDeleted?.data as any).id).toBe(createData.data.session.id);
});
});
describe('Case Events', () => {
it('should emit case:created when case is created', async () => {
const controller = new AbortController();
let receivedData = '';
// Start listening
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 {}
}
});
// Give time to connect
await new Promise((resolve) => setTimeout(resolve, 100));
// Create a case
const caseName = `test-sse-case-${Date.now()}`;
await fetch(`${baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName }),
});
// Wait for events
await new Promise((resolve) => setTimeout(resolve, 200));
// Stop listening
controller.abort();
try {
await fetchPromise;
} catch {}
// Parse events
const events = parseSSEEvents(receivedData);
const caseCreated = events.find((e) => e.event === 'case:created');
expect(caseCreated).toBeDefined();
expect((caseCreated?.data as any).name).toBe(caseName);
// Cleanup (containment-gated)
safeRmHomeTree(join(homedir(), 'codeman-cases', caseName));
});
});
});