Files
stack/apps/web/src/components/mission-control/QueueNotificationFeed.test.tsx
Jason Woltje 72d295edd6
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(web): add queue notification feed
2026-03-08 18:18:07 -05:00

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();
});
});
});