From 7147dc350329711471bde4c401f03862245dbae5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 15:26:12 -0600 Subject: [PATCH] test(web): MS23-P2-009 Mission Control frontend tests --- .../mission-control/AuditLogDrawer.test.tsx | 205 ++++++++++++++++++ .../mission-control/KillAllDialog.test.tsx | 170 +++++++++++++++ .../MissionControlPanel.test.tsx | 153 +++++++++++++ 3 files changed, 528 insertions(+) create mode 100644 apps/web/src/components/mission-control/AuditLogDrawer.test.tsx create mode 100644 apps/web/src/components/mission-control/KillAllDialog.test.tsx create mode 100644 apps/web/src/components/mission-control/MissionControlPanel.test.tsx diff --git a/apps/web/src/components/mission-control/AuditLogDrawer.test.tsx b/apps/web/src/components/mission-control/AuditLogDrawer.test.tsx new file mode 100644 index 0000000..e8afca6 --- /dev/null +++ b/apps/web/src/components/mission-control/AuditLogDrawer.test.tsx @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; + +interface MockButtonProps extends ButtonHTMLAttributes { + children: ReactNode; +} + +interface MockBadgeProps extends HTMLAttributes { + children: ReactNode; +} + +interface AuditLogEntry { + id: string; + userId: string; + sessionId: string; + provider: string; + action: string; + content: string | null; + metadata: unknown; + createdAt: string; +} + +interface AuditLogResponse { + items: AuditLogEntry[]; + total: number; + page: number; + pages: number; +} + +const mockApiGet = vi.fn<(endpoint: string) => Promise>(); + +vi.mock("@/lib/api/client", () => ({ + apiGet: (endpoint: string): Promise => mockApiGet(endpoint), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => ( + + ), +})); + +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children, ...props }: MockBadgeProps): React.JSX.Element => ( + {children} + ), +})); + +import { AuditLogDrawer } from "./AuditLogDrawer"; + +function renderWithQueryClient(ui: React.JSX.Element): ReturnType { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render({ui}); +} + +function responseWith(items: AuditLogEntry[], page: number, pages: number): AuditLogResponse { + return { + items, + total: items.length, + page, + pages, + }; +} + +describe("AuditLogDrawer", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + mockApiGet.mockResolvedValue(responseWith([], 1, 0)); + }); + + it("opens from trigger text and renders empty state", async (): Promise => { + const user = userEvent.setup(); + + renderWithQueryClient(); + + await user.click(screen.getByRole("button", { name: "Audit" })); + + await waitFor((): void => { + expect(screen.getByText("Audit Log")).toBeInTheDocument(); + expect(screen.getByText("No audit entries found.")).toBeInTheDocument(); + }); + }); + + it("renders audit entries with action, session id, and payload", async (): Promise => { + const user = userEvent.setup(); + + mockApiGet.mockResolvedValue( + responseWith( + [ + { + id: "entry-1", + userId: "operator-1", + sessionId: "1234567890abcdef", + provider: "internal", + action: "inject", + content: "Run diagnostics", + metadata: { payload: { ignored: true } }, + createdAt: "2026-03-07T19:00:00.000Z", + }, + ], + 1, + 1 + ) + ); + + renderWithQueryClient(); + + await user.click(screen.getByRole("button", { name: "Audit" })); + + await waitFor((): void => { + expect(screen.getByText("inject")).toBeInTheDocument(); + expect(screen.getByText("12345678")).toBeInTheDocument(); + expect(screen.getByText("Run diagnostics")).toBeInTheDocument(); + }); + }); + + it("supports pagination and metadata payload summary", async (): Promise => { + const user = userEvent.setup(); + + mockApiGet.mockImplementation((endpoint: string): Promise => { + const query = endpoint.split("?")[1] ?? ""; + const params = new URLSearchParams(query); + const page = Number(params.get("page") ?? "1"); + + if (page === 1) { + return Promise.resolve({ + items: [ + { + id: "entry-page-1", + userId: "operator-2", + sessionId: "abcdefgh12345678", + provider: "internal", + action: "pause", + content: "", + metadata: { payload: { reason: "hold" } }, + createdAt: "2026-03-07T19:01:00.000Z", + }, + ], + total: 2, + page: 1, + pages: 2, + }); + } + + return Promise.resolve({ + items: [ + { + id: "entry-page-2", + userId: "operator-3", + sessionId: "zzzz111122223333", + provider: "internal", + action: "kill", + content: null, + metadata: { payload: { force: true } }, + createdAt: "2026-03-07T19:02:00.000Z", + }, + ], + total: 2, + page: 2, + pages: 2, + }); + }); + + renderWithQueryClient(); + + await user.click(screen.getByRole("button", { name: "Audit" })); + + await waitFor((): void => { + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(screen.getByText("reason=hold")).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: "Next" })); + + await waitFor((): void => { + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + expect(screen.getByText("force=true")).toBeInTheDocument(); + }); + }); + + it("includes sessionId filter in query string", async (): Promise => { + const user = userEvent.setup(); + + renderWithQueryClient(); + + await user.click(screen.getByRole("button", { name: "Audit" })); + + await waitFor((): void => { + expect(mockApiGet).toHaveBeenCalled(); + }); + + const firstCall = mockApiGet.mock.calls[0]; + const endpoint = firstCall?.[0] ?? ""; + + expect(endpoint).toContain("sessionId=session+7"); + }); +}); diff --git a/apps/web/src/components/mission-control/KillAllDialog.test.tsx b/apps/web/src/components/mission-control/KillAllDialog.test.tsx new file mode 100644 index 0000000..6bdc56a --- /dev/null +++ b/apps/web/src/components/mission-control/KillAllDialog.test.tsx @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { + ButtonHTMLAttributes, + InputHTMLAttributes, + LabelHTMLAttributes, + ReactNode, +} from "react"; + +interface MockButtonProps extends ButtonHTMLAttributes { + children: ReactNode; +} + +interface MockInputProps extends InputHTMLAttributes { + children?: ReactNode; +} + +interface MockLabelProps extends LabelHTMLAttributes { + children: ReactNode; +} + +interface MockSession { + id: string; + providerId: string; + providerType: string; + status: "active" | "paused"; + createdAt: Date; + updatedAt: Date; +} + +const mockApiPost = vi.fn<(endpoint: string, body?: unknown) => Promise<{ message: string }>>(); + +vi.mock("@/lib/api/client", () => ({ + apiPost: (endpoint: string, body?: unknown): Promise<{ message: string }> => + mockApiPost(endpoint, body), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => ( + + ), +})); + +vi.mock("@/components/ui/input", () => ({ + Input: ({ ...props }: MockInputProps): React.JSX.Element => , +})); + +vi.mock("@/components/ui/label", () => ({ + Label: ({ children, ...props }: MockLabelProps): React.JSX.Element => ( + + ), +})); + +import { KillAllDialog } from "./KillAllDialog"; + +function makeSession(overrides: Partial): MockSession { + return { + id: "session-1", + providerId: "internal", + providerType: "internal", + status: "active", + createdAt: new Date("2026-03-07T10:00:00.000Z"), + updatedAt: new Date("2026-03-07T10:01:00.000Z"), + ...overrides, + }; +} + +describe("KillAllDialog", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + mockApiPost.mockResolvedValue({ message: "killed" }); + }); + + it("renders trigger button and requires exact confirmation text", async (): Promise => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("button", { name: "Kill All" })); + + const confirmInput = screen.getByLabelText("Type KILL ALL to confirm"); + const confirmButton = screen.getByRole("button", { name: "Kill All Agents" }); + + expect(confirmButton).toBeDisabled(); + + await user.type(confirmInput, "kill all"); + expect(confirmButton).toBeDisabled(); + + await user.clear(confirmInput); + await user.type(confirmInput, "KILL ALL"); + + expect(confirmButton).toBeEnabled(); + }); + + it("kills only internal sessions by default and invokes completion callback", async (): Promise => { + const onComplete = vi.fn<() => void>(); + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: "Kill All" })); + await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL"); + await user.click(screen.getByRole("button", { name: "Kill All Agents" })); + + await waitFor((): void => { + expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-1/kill", { + force: true, + }); + }); + + expect(mockApiPost).not.toHaveBeenCalledWith("/api/mission-control/sessions/external-1/kill", { + force: true, + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it("kills all providers when all scope is selected", async (): Promise => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: "Kill All" })); + await user.click(screen.getByRole("radio", { name: /All providers \(2\)/ })); + await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL"); + await user.click(screen.getByRole("button", { name: "Kill All Agents" })); + + await waitFor((): void => { + expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-2/kill", { + force: true, + }); + expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/external-2/kill", { + force: true, + }); + }); + }); + + it("shows empty-scope warning when internal sessions are unavailable", async (): Promise => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: "Kill All" })); + await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL"); + + expect(screen.getByText("No sessions in the selected scope.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Kill All Agents" })).toBeDisabled(); + }); +}); diff --git a/apps/web/src/components/mission-control/MissionControlPanel.test.tsx b/apps/web/src/components/mission-control/MissionControlPanel.test.tsx new file mode 100644 index 0000000..34a10f0 --- /dev/null +++ b/apps/web/src/components/mission-control/MissionControlPanel.test.tsx @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { MAX_PANEL_COUNT, MissionControlPanel, type PanelConfig } from "./MissionControlPanel"; + +interface MockButtonProps extends ButtonHTMLAttributes { + children: ReactNode; +} + +interface MockOrchestratorPanelProps { + sessionId?: string; + onClose?: () => void; + closeDisabled?: boolean; + onExpand?: () => void; + expanded?: boolean; +} + +const mockOrchestratorPanel = vi.fn<(props: MockOrchestratorPanelProps) => React.JSX.Element>(); + +vi.mock("@/components/mission-control/OrchestratorPanel", () => ({ + OrchestratorPanel: (props: MockOrchestratorPanelProps): React.JSX.Element => + mockOrchestratorPanel(props), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => ( + + ), +})); + +function buildPanels(count: number): PanelConfig[] { + return Array.from({ length: count }, (_, index) => ({ + sessionId: `session-${String(index + 1)}`, + })); +} + +describe("MissionControlPanel", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + + mockOrchestratorPanel.mockImplementation( + ({ sessionId, closeDisabled, expanded }: MockOrchestratorPanelProps): React.JSX.Element => ( +
+ ) + ); + }); + + it("renders the panel grid and default heading", (): void => { + render( + void>()} + onRemovePanel={vi.fn<(index: number) => void>()} + onExpandPanel={vi.fn<(index: number) => void>()} + /> + ); + + expect(screen.getByRole("heading", { name: "Panels" })).toBeInTheDocument(); + expect(screen.getAllByTestId("orchestrator-panel")).toHaveLength(1); + }); + + it("calls onAddPanel when the add button is clicked", (): void => { + const onAddPanel = vi.fn<() => void>(); + + render( + void>()} + onExpandPanel={vi.fn<(index: number) => void>()} + /> + ); + + fireEvent.click(screen.getByRole("button", { name: "Add panel" })); + + expect(onAddPanel).toHaveBeenCalledTimes(1); + }); + + it("disables add panel at the configured maximum", (): void => { + render( + void>()} + onRemovePanel={vi.fn<(index: number) => void>()} + onExpandPanel={vi.fn<(index: number) => void>()} + /> + ); + + const addButton = screen.getByRole("button", { name: "Add panel" }); + + expect(addButton).toBeDisabled(); + expect(addButton).toHaveAttribute("title", "Maximum of 6 panels"); + }); + + it("passes closeDisabled=false when more than one panel exists", (): void => { + render( + void>()} + onRemovePanel={vi.fn<(index: number) => void>()} + onExpandPanel={vi.fn<(index: number) => void>()} + /> + ); + + const renderedPanels = screen.getAllByTestId("orchestrator-panel"); + expect(renderedPanels).toHaveLength(2); + + for (const panel of renderedPanels) { + expect(panel).toHaveAttribute("data-close-disabled", "false"); + } + }); + + it("renders only the expanded panel in focused mode", (): void => { + render( + void>()} + onRemovePanel={vi.fn<(index: number) => void>()} + onExpandPanel={vi.fn<(index: number) => void>()} + /> + ); + + const renderedPanels = screen.getAllByTestId("orchestrator-panel"); + + expect(renderedPanels).toHaveLength(1); + expect(renderedPanels[0]).toHaveAttribute("data-session-id", "session-2"); + expect(renderedPanels[0]).toHaveAttribute("data-expanded", "true"); + }); + + it("handles Escape key by toggling expanded panel", async (): Promise => { + const onExpandPanel = vi.fn<(index: number) => void>(); + + render( + void>()} + onRemovePanel={vi.fn<(index: number) => void>()} + onExpandPanel={onExpandPanel} + /> + ); + + fireEvent.keyDown(window, { key: "Escape" }); + + await waitFor((): void => { + expect(onExpandPanel).toHaveBeenCalledWith(0); + }); + }); +});