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/BargeInInput.test.tsx b/apps/web/src/components/mission-control/BargeInInput.test.tsx new file mode 100644 index 0000000..83bce0e --- /dev/null +++ b/apps/web/src/components/mission-control/BargeInInput.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as MosaicUi from "@mosaic/ui"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +interface MockButtonProps extends ButtonHTMLAttributes { + children: ReactNode; +} + +const mockApiPost = vi.fn<(endpoint: string, body?: unknown) => Promise<{ message?: string }>>(); +const mockShowToast = vi.fn<(message: string, variant?: string) => void>(); +const useToastSpy = vi.spyOn(MosaicUi, "useToast"); + +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 => ( + + ), +})); + +import { BargeInInput } from "./BargeInInput"; + +describe("BargeInInput", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + mockApiPost.mockResolvedValue({ message: "ok" }); + useToastSpy.mockReturnValue({ + showToast: mockShowToast, + removeToast: vi.fn(), + } as ReturnType); + }); + + afterEach((): void => { + vi.unstubAllGlobals(); + }); + + it("renders input controls and keeps send disabled for empty content", (): void => { + render(); + + expect(screen.getByLabelText("Inject message")).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Pause before send" })).not.toBeChecked(); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + }); + + it("sends a trimmed message and clears the textarea", async (): Promise => { + const onSent = vi.fn<() => void>(); + const user = userEvent.setup(); + + render(); + + const textarea = screen.getByLabelText("Inject message"); + await user.type(textarea, " execute plan "); + await user.click(screen.getByRole("button", { name: "Send" })); + + await waitFor((): void => { + expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/inject", { + content: "execute plan", + }); + }); + + expect(onSent).toHaveBeenCalledTimes(1); + expect(textarea).toHaveValue(""); + }); + + it("pauses and resumes the session around injection when checkbox is enabled", async (): Promise => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("checkbox", { name: "Pause before send" })); + await user.type(screen.getByLabelText("Inject message"), "hello world"); + await user.click(screen.getByRole("button", { name: "Send" })); + + await waitFor((): void => { + expect(mockApiPost).toHaveBeenCalledTimes(3); + }); + + const calls = mockApiPost.mock.calls as [string, unknown?][]; + + expect(calls[0]).toEqual(["/api/mission-control/sessions/session-2/pause", undefined]); + expect(calls[1]).toEqual([ + "/api/mission-control/sessions/session-2/inject", + { content: "hello world" }, + ]); + expect(calls[2]).toEqual(["/api/mission-control/sessions/session-2/resume", undefined]); + }); + + it("submits with Enter and does not submit on Shift+Enter", async (): Promise => { + const user = userEvent.setup(); + + render(); + + const textarea = screen.getByLabelText("Inject message"); + await user.type(textarea, "first"); + fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: true }); + + expect(mockApiPost).not.toHaveBeenCalled(); + + fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: false }); + + await waitFor((): void => { + expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-3/inject", { + content: "first", + }); + }); + }); + + it("shows an inline error and toast when injection fails", async (): Promise => { + const user = userEvent.setup(); + mockApiPost.mockRejectedValueOnce(new Error("Injection failed")); + + render(); + + await user.type(screen.getByLabelText("Inject message"), "help"); + await user.click(screen.getByRole("button", { name: "Send" })); + + await waitFor((): void => { + expect(screen.getByRole("alert")).toHaveTextContent("Injection failed"); + }); + + expect(mockShowToast).toHaveBeenCalledWith("Injection failed", "error"); + }); + + it("reports resume failures after a successful send", async (): Promise => { + const user = userEvent.setup(); + + mockApiPost + .mockResolvedValueOnce({ message: "paused" }) + .mockResolvedValueOnce({ message: "sent" }) + .mockRejectedValueOnce(new Error("resume failed")); + + render(); + + await user.click(screen.getByRole("checkbox", { name: "Pause before send" })); + await user.type(screen.getByLabelText("Inject message"), "deploy now"); + await user.click(screen.getByRole("button", { name: "Send" })); + + await waitFor((): void => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Message sent, but failed to resume session: resume failed" + ); + }); + + expect(mockShowToast).toHaveBeenCalledWith( + "Message sent, but failed to resume session: resume failed", + "error" + ); + }); +}); diff --git a/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx b/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx new file mode 100644 index 0000000..d7e95cd --- /dev/null +++ b/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; + +interface MockButtonProps extends ButtonHTMLAttributes { + children: ReactNode; +} + +interface MockContainerProps extends HTMLAttributes { + children: ReactNode; +} + +interface MockSession { + id: string; + providerId: string; + providerType: string; + status: "active" | "paused" | "killed"; + createdAt: string; + updatedAt: string; + metadata?: Record; +} + +const mockApiGet = vi.fn<(endpoint: string) => Promise>(); +const mockApiPost = vi.fn<(endpoint: string, body?: unknown) => Promise<{ message: string }>>(); +const mockKillAllDialog = vi.fn<() => React.JSX.Element>(); + +vi.mock("@/lib/api/client", () => ({ + apiGet: (endpoint: string): Promise => mockApiGet(endpoint), + apiPost: (endpoint: string, body?: unknown): Promise<{ message: string }> => + mockApiPost(endpoint, body), +})); + +vi.mock("@/components/mission-control/KillAllDialog", () => ({ + KillAllDialog: (): React.JSX.Element => mockKillAllDialog(), +})); + +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}

+ ), +})); + +import { GlobalAgentRoster } from "./GlobalAgentRoster"; + +function renderWithQueryClient(ui: React.JSX.Element): ReturnType { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render({ui}); +} + +function makeSession(overrides: Partial): MockSession { + return { + id: "session-12345678", + providerId: "internal", + providerType: "internal", + status: "active", + createdAt: "2026-03-07T10:00:00.000Z", + updatedAt: "2026-03-07T10:01:00.000Z", + ...overrides, + }; +} + +function getRowForSessionLabel(label: string): HTMLElement { + const sessionLabel = screen.getByText(label); + const row = sessionLabel.closest('[role="button"]'); + + if (!(row instanceof HTMLElement)) { + throw new Error(`Expected a row element for session label ${label}`); + } + + return row; +} + +describe("GlobalAgentRoster", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + + mockApiGet.mockResolvedValue([]); + mockApiPost.mockResolvedValue({ message: "ok" }); + + mockKillAllDialog.mockImplementation( + (): React.JSX.Element =>
kill-all-dialog
+ ); + }); + + afterEach((): void => { + vi.unstubAllGlobals(); + }); + + it("renders the empty state when no active sessions are returned", async (): Promise => { + renderWithQueryClient(); + + await waitFor((): void => { + expect(screen.getByText("No active agents")).toBeInTheDocument(); + }); + + expect(screen.queryByTestId("kill-all-dialog")).not.toBeInTheDocument(); + }); + + it("groups sessions by provider and shows kill-all control when sessions exist", async (): Promise => { + mockApiGet.mockResolvedValue([ + makeSession({ id: "alpha123456", providerId: "internal", providerType: "internal" }), + makeSession({ id: "bravo123456", providerId: "codex", providerType: "openai" }), + ]); + + renderWithQueryClient(); + + await waitFor((): void => { + expect(screen.getByText("internal")).toBeInTheDocument(); + expect(screen.getByText("codex (openai)")).toBeInTheDocument(); + }); + + expect(screen.getByText("alpha123")).toBeInTheDocument(); + expect(screen.getByText("bravo123")).toBeInTheDocument(); + expect(screen.getByTestId("kill-all-dialog")).toBeInTheDocument(); + }); + + it("calls onSelectSession on row click and keyboard activation", async (): Promise => { + const onSelectSession = vi.fn<(sessionId: string) => void>(); + + mockApiGet.mockResolvedValue([makeSession({ id: "target123456" })]); + + renderWithQueryClient(); + + await waitFor((): void => { + expect(screen.getByText("target12")).toBeInTheDocument(); + }); + + const row = getRowForSessionLabel("target12"); + + fireEvent.click(row); + + fireEvent.keyDown(row, { key: "Enter" }); + + expect(onSelectSession).toHaveBeenCalledTimes(2); + expect(onSelectSession).toHaveBeenNthCalledWith(1, "target123456"); + expect(onSelectSession).toHaveBeenNthCalledWith(2, "target123456"); + }); + + it("kills a session from the roster", async (): Promise => { + mockApiGet.mockResolvedValue([makeSession({ id: "killme123456" })]); + + renderWithQueryClient(); + + await waitFor((): void => { + expect(screen.getByRole("button", { name: "Kill session killme12" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Kill session killme12" })); + + await waitFor((): void => { + expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", { + force: false, + }); + }); + }); + + it("collapses and reopens provider groups", async (): Promise => { + mockApiGet.mockResolvedValue([makeSession({ id: "grouped12345" })]); + + renderWithQueryClient(); + + await waitFor((): void => { + expect(screen.getByText("grouped1")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /internal/i })); + + expect(screen.queryByText("grouped1")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /internal/i })); + + expect(screen.getByText("grouped1")).toBeInTheDocument(); + }); +}); 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/MissionControlLayout.test.tsx b/apps/web/src/components/mission-control/MissionControlLayout.test.tsx new file mode 100644 index 0000000..3b4b35f --- /dev/null +++ b/apps/web/src/components/mission-control/MissionControlLayout.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +interface MockButtonProps extends ButtonHTMLAttributes { + children: ReactNode; +} + +const mockGlobalAgentRoster = vi.fn(); +const mockMissionControlPanel = vi.fn(); + +vi.mock("@/components/mission-control/AuditLogDrawer", () => ({ + AuditLogDrawer: ({ trigger }: { trigger: ReactNode }): React.JSX.Element => ( +
{trigger}
+ ), +})); + +vi.mock("@/components/mission-control/GlobalAgentRoster", () => ({ + GlobalAgentRoster: (props: unknown): React.JSX.Element => { + mockGlobalAgentRoster(props); + return
; + }, +})); + +vi.mock("@/components/mission-control/MissionControlPanel", () => ({ + MissionControlPanel: (props: unknown): React.JSX.Element => { + mockMissionControlPanel(props); + return
; + }, + MAX_PANEL_COUNT: 6, + MIN_PANEL_COUNT: 1, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => ( + + ), +})); + +import { MissionControlLayout } from "./MissionControlLayout"; + +describe("MissionControlLayout", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach((): void => { + vi.unstubAllGlobals(); + }); + + it("renders without crashing", (): void => { + render(); + + expect(screen.getByRole("region", { name: "Mission Control" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Audit Log" })).toBeInTheDocument(); + }); + + it("renders sidebar and panel grid container", (): void => { + render(); + + const region = screen.getByRole("region", { name: "Mission Control" }); + + expect(region.querySelector(".grid")).toBeInTheDocument(); + expect(region.querySelector("aside")).toBeInTheDocument(); + expect(region.querySelector("main")).toBeInTheDocument(); + expect(screen.getByTestId("global-agent-roster")).toBeInTheDocument(); + expect(screen.getByTestId("mission-control-panel")).toBeInTheDocument(); + }); +}); 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); + }); + }); +}); diff --git a/apps/web/src/components/mission-control/OrchestratorPanel.test.tsx b/apps/web/src/components/mission-control/OrchestratorPanel.test.tsx new file mode 100644 index 0000000..9a9f3da --- /dev/null +++ b/apps/web/src/components/mission-control/OrchestratorPanel.test.tsx @@ -0,0 +1,218 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; + +interface MockButtonProps extends ButtonHTMLAttributes { + children: ReactNode; +} + +interface MockContainerProps extends HTMLAttributes { + children: ReactNode; +} + +type MockConnectionStatus = "connected" | "connecting" | "error"; +type MockRole = "user" | "assistant" | "tool" | "system"; + +interface MockMessage { + id: string; + role: MockRole; + content: string; + timestamp: string; +} + +interface MockSession { + id: string; + status: string; +} + +interface MockSessionStreamResult { + messages: MockMessage[]; + status: MockConnectionStatus; + error: string | null; +} + +interface MockSessionsResult { + sessions: MockSession[]; + loading: boolean; + error: Error | null; +} + +interface MockPanelControlsProps { + sessionId: string; + status: string; + onStatusChange?: (nextStatus: string) => void; +} + +interface MockBargeInInputProps { + sessionId: string; +} + +const mockUseSessionStream = vi.fn<(sessionId: string) => MockSessionStreamResult>(); +const mockUseSessions = vi.fn<() => MockSessionsResult>(); +const mockPanelControls = vi.fn<(props: MockPanelControlsProps) => React.JSX.Element>(); +const mockBargeInInput = vi.fn<(props: MockBargeInInputProps) => React.JSX.Element>(); + +vi.mock("date-fns", () => ({ + formatDistanceToNow: (): string => "moments ago", +})); + +vi.mock("@/hooks/useMissionControl", () => ({ + useSessionStream: (sessionId: string): MockSessionStreamResult => mockUseSessionStream(sessionId), + useSessions: (): MockSessionsResult => mockUseSessions(), +})); + +vi.mock("@/components/mission-control/PanelControls", () => ({ + PanelControls: (props: MockPanelControlsProps): React.JSX.Element => mockPanelControls(props), +})); + +vi.mock("@/components/mission-control/BargeInInput", () => ({ + BargeInInput: (props: MockBargeInInputProps): React.JSX.Element => mockBargeInInput(props), +})); + +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/scroll-area", () => ({ + ScrollArea: ({ children, ...props }: MockContainerProps): React.JSX.Element => ( +
{children}
+ ), +})); + +import { OrchestratorPanel } from "./OrchestratorPanel"; + +beforeAll((): void => { + Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); +}); + +describe("OrchestratorPanel", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + + mockUseSessionStream.mockReturnValue({ + messages: [], + status: "connecting", + error: null, + }); + + mockUseSessions.mockReturnValue({ + sessions: [], + loading: false, + error: null, + }); + + mockPanelControls.mockImplementation( + ({ status }: MockPanelControlsProps): React.JSX.Element => ( +
status:{status}
+ ) + ); + + mockBargeInInput.mockImplementation( + ({ sessionId }: MockBargeInInputProps): React.JSX.Element => ( +