feat: container monitoring frontend UI (PR-2)
- Custom ToastContext + ToastProvider + ToastContainer (~170 lines, no deps) - useEvents() SSE hook with exponential backoff reconnect - EventProvider context for app-wide SSE stream sharing - Event-to-toast bridge with severity mapping and deduplication - Real-time status badge updates replacing 30s polling - EventSource auth probe (401/429 detection via fetch) - 14 frontend tests (useEvents + toast-rules) Quality gates: vitest 14 passed, tsc clean, eslint clean
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user