226 lines
7.0 KiB
TypeScript
226 lines
7.0 KiB
TypeScript
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<HTMLButtonElement> {
|
|
children: ReactNode;
|
|
}
|
|
|
|
interface MockContainerProps extends HTMLAttributes<HTMLElement> {
|
|
children: ReactNode;
|
|
}
|
|
|
|
interface MockEventSourceInstance {
|
|
url: string;
|
|
onerror: ((event: Event) => void) | null;
|
|
onmessage: ((event: MessageEvent<string>) => void) | null;
|
|
close: ReturnType<typeof vi.fn>;
|
|
emitMessage: (payload: unknown) => void;
|
|
}
|
|
|
|
interface QueueNotification {
|
|
id: string;
|
|
agent: string;
|
|
filename: string;
|
|
payload: unknown;
|
|
createdAt: string;
|
|
}
|
|
|
|
const mockApiGet = vi.fn<(endpoint: string) => Promise<QueueNotification[]>>();
|
|
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<QueueNotification[]> => mockApiGet(endpoint),
|
|
apiPost: (endpoint: string): Promise<{ success: true; id: string }> => mockApiPost(endpoint),
|
|
}));
|
|
|
|
vi.mock("@mosaic/ui", () => ({
|
|
useToast: (): { showToast: typeof mockShowToast; removeToast: ReturnType<typeof vi.fn> } => ({
|
|
showToast: mockShowToast,
|
|
removeToast: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/button", () => ({
|
|
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
|
|
<button {...props}>{children}</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/badge", () => ({
|
|
Badge: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
|
|
<span {...props}>{children}</span>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/card", () => ({
|
|
Card: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
|
|
<section {...props}>{children}</section>
|
|
),
|
|
CardHeader: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
|
|
<header {...props}>{children}</header>
|
|
),
|
|
CardContent: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
|
|
<div {...props}>{children}</div>
|
|
),
|
|
CardTitle: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
|
|
<h2 {...props}>{children}</h2>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/collapsible", () => ({
|
|
Collapsible: ({ children }: MockContainerProps): React.JSX.Element => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/scroll-area", () => ({
|
|
ScrollArea: ({ children, ...props }: MockContainerProps): React.JSX.Element => (
|
|
<div {...props}>{children}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/skeleton", () => ({
|
|
Skeleton: (props: HTMLAttributes<HTMLDivElement>): React.JSX.Element => <div {...props} />,
|
|
}));
|
|
|
|
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>): 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<void> => {
|
|
mockApiGet.mockResolvedValue([
|
|
makeNotification({ id: "notif-1", agent: "mosaic" }),
|
|
makeNotification({
|
|
id: "notif-2",
|
|
agent: "dyor",
|
|
payload: { taskId: "MS24-WEB-002", eventType: "task.blocked" },
|
|
}),
|
|
]);
|
|
|
|
render(<QueueNotificationFeed />);
|
|
|
|
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<void> => {
|
|
const user = userEvent.setup();
|
|
mockApiGet.mockResolvedValue([makeNotification({ id: "notif-ack" })]);
|
|
|
|
render(<QueueNotificationFeed />);
|
|
|
|
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<void> => {
|
|
mockApiGet.mockResolvedValue([]);
|
|
|
|
render(<QueueNotificationFeed />);
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByText("No pending notifications")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("refreshes the list when an SSE message arrives", async (): Promise<void> => {
|
|
mockApiGet.mockResolvedValue([makeNotification({ id: "notif-before" })]);
|
|
|
|
render(<QueueNotificationFeed />);
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|