import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; import { useEvents } from "./use-events"; // Mock the API client vi.mock("../api/events", () => ({ createEventSource: vi.fn(), probeEventStreamStatus: vi.fn().mockResolvedValue(null), })); import { createEventSource, probeEventStreamStatus } from "../api/events"; const mockedCreateEventSource = vi.mocked(createEventSource); const mockedProbeEventStreamStatus = vi.mocked(probeEventStreamStatus); describe("useEvents", () => { let mockEs: EventSource; beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); mockEs = { close: vi.fn(), onopen: null, onmessage: null, onerror: null, get readyState() { return EventSource.OPEN; }, url: "http://localhost:8000/events/stream", } as unknown as EventSource; mockedCreateEventSource.mockReturnValue(mockEs); mockedProbeEventStreamStatus.mockResolvedValue(null); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it("parses sse event and adds to state", async () => { const { result } = renderHook(() => useEvents()); // Simulate connection open act(() => { mockEs.onopen?.({} as Event); }); const payload = { event: "instance.started", instance_id: "abc-123", status: "starting", message: "Container starting...", metadata: {}, timestamp: "2026-05-28T12:00:00Z", correlation_id: "corr-1", }; act(() => { mockEs.onmessage?.({ data: JSON.stringify(payload), } as MessageEvent); }); await waitFor(() => { expect(result.current.events).toHaveLength(1); expect(result.current.events[0].instance_id).toBe("abc-123"); }); expect(result.current.connected).toBe(true); }); it("reconnects with exponential backoff on error", async () => { renderHook(() => useEvents()); act(() => { mockEs.onerror?.({} as Event); }); expect(mockEs.close).toHaveBeenCalled(); expect(mockedCreateEventSource).toHaveBeenCalledTimes(1); // Advance past first backoff (should be ~1000ms) await act(async () => { vi.advanceTimersByTime(1200); }); expect(mockedCreateEventSource).toHaveBeenCalledTimes(2); // Trigger another error const secondEs = mockedCreateEventSource.mock.results[1] .value as EventSource; act(() => { secondEs.onerror?.({} as Event); }); // Advance past second backoff (should be ~2000ms) await act(async () => { vi.advanceTimersByTime(2500); }); expect(mockedCreateEventSource).toHaveBeenCalledTimes(3); }); it("caps reconnect delay at 30 seconds", async () => { renderHook(() => useEvents()); // Trigger 6 errors to get past 1s, 2s, 4s, 8s, 16s for (let i = 0; i < 6; i++) { const currentEs = i === 0 ? mockEs : (mockedCreateEventSource.mock.results[i]?.value as EventSource); act(() => { currentEs.onerror?.({} as Event); }); // Advance enough to trigger next reconnect await act(async () => { vi.advanceTimersByTime(35000); }); } expect(mockedCreateEventSource.mock.calls.length).toBeGreaterThan(5); }); it("stops reconnecting and redirects on 401", async () => { mockedProbeEventStreamStatus.mockResolvedValue(401); const originalLocation = window.location; // @ts-expect-error - overriding readonly location for test delete window.location; // @ts-expect-error - mock location window.location = { ...originalLocation, assign: vi.fn() }; renderHook(() => useEvents()); act(() => { mockEs.onerror?.({} as Event); }); await act(async () => { vi.advanceTimersByTime(1500); }); expect(window.location.assign).toHaveBeenCalled(); // @ts-expect-error - restoring location window.location = originalLocation; }); it("adds 5s penalty on 429", async () => { mockedProbeEventStreamStatus.mockResolvedValue(429); renderHook(() => useEvents()); act(() => { mockEs.onerror?.({} as Event); }); // First timeout fires (~1s), detects 429, schedules penalty timeout (~7s later) await act(async () => { vi.advanceTimersByTime(2000); }); expect(mockedCreateEventSource).toHaveBeenCalledTimes(1); // Advance past penalty delay (need enough for delay + 5000) await act(async () => { vi.advanceTimersByTime(10000); }); expect(mockedCreateEventSource).toHaveBeenCalledTimes(2); }); it("cleans up EventSource on unmount", () => { const { unmount } = renderHook(() => useEvents()); unmount(); expect(mockEs.close).toHaveBeenCalled(); }); it("exposes reconnectCount", async () => { const { result } = renderHook(() => useEvents()); act(() => { mockEs.onerror?.({} as Event); }); await waitFor(() => { expect(result.current.reconnectCount).toBeGreaterThan(0); }); }); });