Files
Codeman/test/sse-subscription-filter.test.ts
T
arkonandClaude Opus 4.8 458fb81cbe feat(api): establish stable HTTP contract — uniform {success,data} envelope, status codes, /api/v1
Point 1 of the v1.0 lock-in: commit to a stable HTTP API (the cleanest, fullest form).

Core (centralized):
- Every JSON /api response now uses ONE envelope via a Fastify preSerialization hook (src/web/server.ts): success -> { success:true, data:<payload> }; error -> { success:false, error, errorCode } with a conventional HTTP status. Non-JSON routes (file-raw, tail-file SSE, download, screenshots, /q redirect, WS) are skipped.
- Error-code -> HTTP status is a single source of truth (httpStatusForErrorCode in src/types/api.ts): 400/401/404/409/422/429/500. Expanded ApiErrorCode (added UNAUTHORIZED, CONFLICT, RATE_LIMITED). Errors are no longer HTTP 200.
- Versioned alias: /api/v1/* rewrites to /api/* (rewriteApiV1Url), so external clients pin to a stable surface while the bundled UI keeps using /api/*.
- Handlers stripped of manual 'success:true' (50 across 14 route files) so they return bare payloads the hook wraps uniformly; fixed the mux DELETE {success:<bool>} envelope collision (-> {killed}).

Frontend (48 call sites across 10 files):
- _apiJson() auto-unwraps { success:true, data } -> data (null on error), so most bare-shape readers are transparent. Raw-fetch sites relocate payload reads under .data; success/res.ok/error checks unchanged.

Docs: new docs/api-reference.md (envelope, status table, error codes, /api/v1, SSE); versioning-policy.md flipped — the HTTP/SSE API is now part of the stable, SemVer-covered surface.

Verification: full unit/route suite green (2680 passed) incl. ~166 updated assertions across 24 test files; typecheck/lint/format/frontend-syntax clean; a headless-chromium smoke loaded the migrated UI and drove the panels with 0 console/page errors; /api/status and /api/v1/status confirmed returning the uniform envelope live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:30:43 +02:00

451 lines
14 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { WebServer } from '../src/web/server.js';
const TEST_PORT = 3212;
// 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<Array<{ event: string; data: unknown }>> {
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);
}
describe('SSE Subscription Filtering', () => {
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);
it('should accept sessions query parameter without error', async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 500);
try {
const response = await fetch(`${baseUrl}/api/events?sessions=abc,def`, {
signal: controller.signal,
});
expect(response.headers.get('content-type')).toBe('text/event-stream');
} catch (err: any) {
if (err.name !== 'AbortError') throw err;
} finally {
clearTimeout(timeout);
}
});
it('should send init event regardless of session filter', async () => {
const events = await collectSSEEvents(baseUrl, '?sessions=nonexistent-id', 500);
const initEvent = events.find((e) => e.event === 'init');
expect(initEvent).toBeDefined();
expect((initEvent?.data as any).sessions).toBeDefined();
});
it('should receive all events when no sessions param is provided (backwards-compatible)', async () => {
// Start two SSE listeners: one with no filter, one with a filter for a nonexistent session
const controller1 = new AbortController();
const controller2 = new AbortController();
let unfilteredData = '';
let filteredData = '';
const fetch1 = fetch(`${baseUrl}/api/events`, {
signal: controller1.signal,
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
unfilteredData += new TextDecoder().decode(value);
}
} catch {
/* expected */
}
}
});
const fetch2 = fetch(`${baseUrl}/api/events?sessions=nonexistent-session`, {
signal: controller2.signal,
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
filteredData += new TextDecoder().decode(value);
}
} catch {
/* expected */
}
}
});
// Wait for connections to establish
await new Promise((resolve) => setTimeout(resolve, 200));
// Create a session — this emits session:created (a global-ish event that has id but
// the nonexistent filter won't match it)
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const createData = await createRes.json();
const sessionId = createData.data.session.id;
// Wait for events to arrive
await new Promise((resolve) => setTimeout(resolve, 300));
// Stop listening
controller1.abort();
controller2.abort();
try {
await fetch1;
} catch {
/* expected */
}
try {
await fetch2;
} catch {
/* expected */
}
const unfilteredEvents = parseSSEEvents(unfilteredData);
const filteredEvents = parseSSEEvents(filteredData);
// Unfiltered client should receive the session:created event
const unfilteredCreated = unfilteredEvents.find((e) => e.event === 'session:created');
expect(unfilteredCreated).toBeDefined();
expect((unfilteredCreated?.data as any).id).toBe(sessionId);
// Lifecycle/metadata events (session:created/updated/deleted, ralph:*, etc.) are
// intentionally broadcast to ALL clients regardless of the ?sessions= filter — only
// the high-volume terminal stream is filtered per-session (see sse-stream-manager
// broadcast(): "Subscription filtering is intentionally NOT applied here"). So the
// filtered client still receives session:created.
const filteredCreated = filteredEvents.find((e) => e.event === 'session:created');
expect(filteredCreated).toBeDefined();
// Both should have received the init event (it has no sessionId)
expect(unfilteredEvents.find((e) => e.event === 'init')).toBeDefined();
expect(filteredEvents.find((e) => e.event === 'init')).toBeDefined();
// Cleanup
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
});
it('should deliver session events to a client subscribed to that session', async () => {
// First create a session so we know the ID
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const createData = await createRes.json();
const sessionId = createData.data.session.id;
// Now connect SSE with the session filter
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 */
}
}
});
// Wait for connection
await new Promise((resolve) => setTimeout(resolve, 200));
// Delete the session — this emits session:deleted with { id: sessionId }
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
// Wait for events
await new Promise((resolve) => setTimeout(resolve, 300));
controller.abort();
try {
await fetchPromise;
} catch {
/* expected */
}
const events = parseSSEEvents(receivedData);
// Should receive session:deleted because we're subscribed to this session
const deletedEvent = events.find((e) => e.event === 'session:deleted');
expect(deletedEvent).toBeDefined();
expect((deletedEvent?.data as any).id).toBe(sessionId);
});
it('should filter out events for sessions not in the subscription', async () => {
// Create two sessions
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const session1 = (await createRes1.json()).data.session;
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const session2 = (await createRes2.json()).data.session;
// Connect SSE subscribed ONLY to session1
const controller = new AbortController();
let receivedData = '';
const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${session1.id}`, {
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 */
}
}
});
// Wait for connection
await new Promise((resolve) => setTimeout(resolve, 200));
// Delete both sessions — emits session:deleted for each
await fetch(`${baseUrl}/api/sessions/${session1.id}`, { method: 'DELETE' });
await fetch(`${baseUrl}/api/sessions/${session2.id}`, { method: 'DELETE' });
// Wait for events
await new Promise((resolve) => setTimeout(resolve, 300));
controller.abort();
try {
await fetchPromise;
} catch {
/* expected */
}
const events = parseSSEEvents(receivedData);
// Should receive session:deleted for session1
const deleted1 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1.id);
expect(deleted1).toBeDefined();
// Lifecycle events are broadcast to all clients regardless of filter, so a client
// subscribed to session1 still receives session2's session:deleted (only terminal
// output is filtered per-session).
const deleted2 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2.id);
expect(deleted2).toBeDefined();
});
it('should support subscribing to multiple sessions', async () => {
// Create two sessions
const createRes1 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const session1 = (await createRes1.json()).data.session;
const createRes2 = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const session2 = (await createRes2.json()).data.session;
// Connect SSE subscribed to both sessions
const controller = new AbortController();
let receivedData = '';
const fetchPromise = fetch(`${baseUrl}/api/events?sessions=${session1.id},${session2.id}`, {
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 */
}
}
});
// Wait for connection
await new Promise((resolve) => setTimeout(resolve, 200));
// Delete both sessions
await fetch(`${baseUrl}/api/sessions/${session1.id}`, { method: 'DELETE' });
await fetch(`${baseUrl}/api/sessions/${session2.id}`, { method: 'DELETE' });
// Wait for events
await new Promise((resolve) => setTimeout(resolve, 300));
controller.abort();
try {
await fetchPromise;
} catch {
/* expected */
}
const events = parseSSEEvents(receivedData);
// Should receive session:deleted for both sessions
const deleted1 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1.id);
const deleted2 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2.id);
expect(deleted1).toBeDefined();
expect(deleted2).toBeDefined();
});
it('should deliver global events (no sessionId) to filtered clients', async () => {
// Connect with a filter — global events like case:created should still arrive
const controller = new AbortController();
let receivedData = '';
const fetchPromise = fetch(`${baseUrl}/api/events?sessions=some-session-id`, {
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 */
}
}
});
// Wait for connection
await new Promise((resolve) => setTimeout(resolve, 200));
// Create a case — case:created is a global event (no sessionId or id matching a session)
const caseName = `test-filter-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, 300));
controller.abort();
try {
await fetchPromise;
} catch {
/* expected */
}
const events = parseSSEEvents(receivedData);
// case:created should arrive because it has no sessionId — it's a global event
const caseCreated = events.find((e) => e.event === 'case:created');
expect(caseCreated).toBeDefined();
expect((caseCreated?.data as any).name).toBe(caseName);
// 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 */
}
});
});