import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; vi.mock("date-fns", () => ({ formatDistanceToNow: (): string => "5 minutes ago", })); interface MockButtonProps extends ButtonHTMLAttributes { children: ReactNode; } interface MockContainerProps extends HTMLAttributes { children: ReactNode; } interface MockEventSourceInstance { url: string; onerror: ((event: Event) => void) | null; onmessage: ((event: MessageEvent) => void) | null; close: ReturnType; emitMessage: (payload: unknown) => void; } interface QueueNotification { id: string; agent: string; filename: string; payload: unknown; createdAt: string; } const mockApiGet = vi.fn<(endpoint: string) => Promise>(); const mockApiPost = vi.fn<(endpoint: string) => Promise<{ success: true; id: string }>>(); const mockShowToast = vi.fn<(message: string, variant?: string) => void>(); let mockEventSourceInstances: MockEventSourceInstance[] = []; const MockEventSource = vi.fn(function (this: MockEventSourceInstance, url: string): void { this.url = url; this.onerror = null; this.onmessage = null; this.close = vi.fn(); this.emitMessage = (payload: unknown): void => { this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(payload) })); }; mockEventSourceInstances.push(this); }); vi.mock("@/lib/api/client", () => ({ apiGet: (endpoint: string): Promise => mockApiGet(endpoint), apiPost: (endpoint: string): Promise<{ success: true; id: string }> => mockApiPost(endpoint), })); vi.mock("@mosaic/ui", () => ({ useToast: (): { showToast: typeof mockShowToast; removeToast: ReturnType } => ({ showToast: mockShowToast, removeToast: vi.fn(), }), })); vi.mock("@/components/ui/button", () => ({ Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => ( ), })); vi.mock("@/components/ui/badge", () => ({ Badge: ({ children, ...props }: MockContainerProps): React.JSX.Element => ( {children} ), })); vi.mock("@/components/ui/card", () => ({ Card: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
{children}
), CardHeader: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
{children}
), CardContent: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
{children}
), CardTitle: ({ children, ...props }: MockContainerProps): React.JSX.Element => (

{children}

), })); vi.mock("@/components/ui/collapsible", () => ({ Collapsible: ({ children }: MockContainerProps): React.JSX.Element =>
{children}
, })); vi.mock("@/components/ui/scroll-area", () => ({ ScrollArea: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
{children}
), })); vi.mock("@/components/ui/skeleton", () => ({ Skeleton: (props: HTMLAttributes): React.JSX.Element =>
, })); import { QueueNotificationFeed } from "./QueueNotificationFeed"; function latestEventSource(): MockEventSourceInstance { const instance = mockEventSourceInstances[mockEventSourceInstances.length - 1]; if (!instance) { throw new Error("Expected an EventSource instance"); } return instance; } function makeNotification(overrides: Partial): QueueNotification { return { id: "notif-1", agent: "mosaic", filename: "notif-1.json", payload: { taskId: "MS24-WEB-001", eventType: "task.ready", }, createdAt: "2026-03-08T23:00:00.000Z", ...overrides, }; } describe("QueueNotificationFeed", (): void => { beforeEach((): void => { vi.clearAllMocks(); vi.stubGlobal("EventSource", MockEventSource); vi.stubGlobal("fetch", vi.fn()); mockEventSourceInstances = []; mockApiGet.mockResolvedValue([]); mockApiPost.mockResolvedValue({ success: true, id: "notif-1" }); }); afterEach((): void => { vi.unstubAllGlobals(); }); it("loads and renders notifications grouped by agent", async (): Promise => { mockApiGet.mockResolvedValue([ makeNotification({ id: "notif-1", agent: "mosaic" }), makeNotification({ id: "notif-2", agent: "dyor", payload: { taskId: "MS24-WEB-002", eventType: "task.blocked" }, }), ]); render(); await waitFor((): void => { expect(mockApiGet).toHaveBeenCalledWith("/api/orchestrator/api/queue/notifications"); }); expect(screen.getByText("mosaic")).toBeInTheDocument(); expect(screen.getByText("dyor")).toBeInTheDocument(); expect(screen.getByText("MS24-WEB-001")).toBeInTheDocument(); expect(screen.getByText("task.ready")).toBeInTheDocument(); expect(screen.getAllByText("5 minutes ago")).toHaveLength(2); expect(MockEventSource).toHaveBeenCalledWith("/api/orchestrator/queue/notifications/stream"); }); it("acknowledges a notification and removes it from the list", async (): Promise => { const user = userEvent.setup(); mockApiGet.mockResolvedValue([makeNotification({ id: "notif-ack" })]); render(); await waitFor((): void => { expect(screen.getByText("MS24-WEB-001")).toBeInTheDocument(); }); await user.click(screen.getByRole("button", { name: "ACK notification MS24-WEB-001" })); await waitFor((): void => { expect(mockApiPost).toHaveBeenCalledWith( "/api/orchestrator/api/queue/notifications/notif-ack/ack" ); }); await waitFor((): void => { expect(screen.getByText("No pending notifications")).toBeInTheDocument(); }); }); it("renders the empty state when there are no notifications", async (): Promise => { mockApiGet.mockResolvedValue([]); render(); await waitFor((): void => { expect(screen.getByText("No pending notifications")).toBeInTheDocument(); }); }); it("refreshes the list when an SSE message arrives", async (): Promise => { mockApiGet.mockResolvedValue([makeNotification({ id: "notif-before" })]); render(); await waitFor((): void => { expect(screen.getByText("MS24-WEB-001")).toBeInTheDocument(); }); act(() => { latestEventSource().emitMessage([ makeNotification({ id: "notif-after", agent: "sage", payload: { taskId: "MS24-WEB-003", eventType: "task.failed" }, }), ]); }); await waitFor((): void => { expect(screen.getByText("sage")).toBeInTheDocument(); expect(screen.getByText("MS24-WEB-003")).toBeInTheDocument(); expect(screen.getByText("task.failed")).toBeInTheDocument(); }); }); });