|
|
@@ -1,6 +1,6 @@
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
|
import * as fs from 'node:fs/promises';
|
|
|
-import { createServer } from 'node:http';
|
|
|
+import { createServer, get } from 'node:http';
|
|
|
import * as path from 'node:path';
|
|
|
import { createDashboardServer } from './dashboard';
|
|
|
|
|
|
@@ -47,7 +47,90 @@ async function createTempInterviewDir() {
|
|
|
return tempDir;
|
|
|
}
|
|
|
|
|
|
+async function openSseConnection(
|
|
|
+ baseUrl: string,
|
|
|
+ authToken: string,
|
|
|
+ interviewId: string,
|
|
|
+) {
|
|
|
+ return new Promise<{
|
|
|
+ firstChunk: Promise<string>;
|
|
|
+ closed: Promise<void>;
|
|
|
+ }>((resolve, reject) => {
|
|
|
+ const request = get(
|
|
|
+ `${baseUrl}/api/interviews/${interviewId}/events?token=${authToken}`,
|
|
|
+ (response) => {
|
|
|
+ response.setEncoding('utf8');
|
|
|
+
|
|
|
+ let buffer = '';
|
|
|
+ const firstChunk = new Promise<string>((resolveFirst) => {
|
|
|
+ response.on('data', (chunk: string) => {
|
|
|
+ buffer += chunk;
|
|
|
+ if (buffer.includes('event: state')) {
|
|
|
+ resolveFirst(buffer);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ const closed = new Promise<void>((resolveClosed) => {
|
|
|
+ response.once('close', resolveClosed);
|
|
|
+ });
|
|
|
+
|
|
|
+ response.once('error', reject);
|
|
|
+ resolve({ firstChunk, closed });
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ request.once('error', reject);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
describe('dashboard server', () => {
|
|
|
+ describe('server lifecycle', () => {
|
|
|
+ test('close is safe before start and repeated', () => {
|
|
|
+ const dashboard = createDashboardServer({
|
|
|
+ port: 0,
|
|
|
+ outputFolder: 'interview',
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(() => dashboard.close()).not.toThrow();
|
|
|
+ expect(() => dashboard.close()).not.toThrow();
|
|
|
+ });
|
|
|
+
|
|
|
+ test('closes active SSE responses on shutdown', async () => {
|
|
|
+ const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
|
|
|
+ try {
|
|
|
+ dashboard.pushState({
|
|
|
+ interviewId: 'lifecycle-sse',
|
|
|
+ sessionID: 'session-lifecycle',
|
|
|
+ idea: 'Lifecycle SSE',
|
|
|
+ mode: 'awaiting-user',
|
|
|
+ summary: 'Test',
|
|
|
+ title: 'Lifecycle SSE',
|
|
|
+ questions: [],
|
|
|
+ pendingAnswers: null,
|
|
|
+ lastUpdatedAt: Date.now(),
|
|
|
+ filePath: '',
|
|
|
+ nudgeAction: null,
|
|
|
+ });
|
|
|
+
|
|
|
+ const { firstChunk, closed } = await openSseConnection(
|
|
|
+ baseUrl,
|
|
|
+ authToken,
|
|
|
+ 'lifecycle-sse',
|
|
|
+ );
|
|
|
+
|
|
|
+ expect(await firstChunk).toContain('event: state');
|
|
|
+
|
|
|
+ dashboard.close();
|
|
|
+ dashboard.close();
|
|
|
+
|
|
|
+ await closed;
|
|
|
+ } finally {
|
|
|
+ cleanup();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
describe('health endpoint', () => {
|
|
|
test('returns 200 with status ok and counts', async () => {
|
|
|
const { baseUrl, cleanup } = await startDashboard();
|