feat(web): add queue notification feed
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user