From d238ddd3957714c2d4fc4ff0561ab950bf7d6008 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 12:19:55 +0200 Subject: [PATCH] feat(frontend): useSessionStream (EventSource -> store) --- frontend/src/stream/useSessionStream.test.tsx | 28 +++++++++++++++++ frontend/src/stream/useSessionStream.ts | 31 +++++++++++++++++++ frontend/src/test/fakeEventSource.ts | 16 ++++++++++ 3 files changed, 75 insertions(+) create mode 100644 frontend/src/stream/useSessionStream.test.tsx create mode 100644 frontend/src/stream/useSessionStream.ts create mode 100644 frontend/src/test/fakeEventSource.ts diff --git a/frontend/src/stream/useSessionStream.test.tsx b/frontend/src/stream/useSessionStream.test.tsx new file mode 100644 index 00000000..bb771048 --- /dev/null +++ b/frontend/src/stream/useSessionStream.test.tsx @@ -0,0 +1,28 @@ +import { renderHook } from "@testing-library/react"; +import { act } from "react"; +import { FakeEventSource } from "../test/fakeEventSource"; +import { useSessionStream } from "./useSessionStream"; +import { useSessionStore } from "../store/sessionStore"; + +beforeEach(() => { + FakeEventSource.instances = []; + (globalThis as any).EventSource = FakeEventSource; + useSessionStore.getState().resetSession(); +}); + +test("opens an EventSource for the session and feeds events to the store", () => { + renderHook(() => useSessionStream("s1")); + const es = FakeEventSource.instances[0]; + expect(es.url).toContain("/sessions/s1/events"); + act(() => + es.emit({ type: "ui_request", ui_request: { id: "u1", widget: "select" } }) + ); + expect(useSessionStore.getState().pendingWidget?.id).toBe("u1"); +}); + +test("closes the stream on unmount", () => { + const { unmount } = renderHook(() => useSessionStream("s1")); + const es = FakeEventSource.instances[0]; + unmount(); + expect(es.closed).toBe(true); +}); diff --git a/frontend/src/stream/useSessionStream.ts b/frontend/src/stream/useSessionStream.ts new file mode 100644 index 00000000..d3d30742 --- /dev/null +++ b/frontend/src/stream/useSessionStream.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { BASE } from "../api/client"; +import { useSessionStore } from "../store/sessionStore"; +import type { StreamEvent } from "../api/types"; + +export function useSessionStream(sessionId: string | null) { + const [connected, setConnected] = useState(false); + const applyEvent = useSessionStore((s) => s.applyEvent); + + useEffect(() => { + if (!sessionId) return; + + const es = new EventSource(`${BASE}/sessions/${sessionId}/events`); + es.onopen = () => setConnected(true); + es.onerror = () => setConnected(false); + es.onmessage = (ev) => { + try { + applyEvent(JSON.parse(ev.data) as StreamEvent); + } catch { + /* ignore malformed */ + } + }; + + return () => { + es.close(); + setConnected(false); + }; + }, [sessionId, applyEvent]); + + return { connected }; +} diff --git a/frontend/src/test/fakeEventSource.ts b/frontend/src/test/fakeEventSource.ts new file mode 100644 index 00000000..ba4e7089 --- /dev/null +++ b/frontend/src/test/fakeEventSource.ts @@ -0,0 +1,16 @@ +export class FakeEventSource { + static instances: FakeEventSource[] = []; + onmessage: ((e: { data: string }) => void) | null = null; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + closed = false; + constructor(public url: string) { + FakeEventSource.instances.push(this); + } + emit(obj: unknown) { + this.onmessage?.({ data: JSON.stringify(obj) }); + } + close() { + this.closed = true; + } +}