From f0aa3b5a75b1553fedc77f80995087def2c05e5e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 15:22:04 -0600 Subject: [PATCH 1/2] test(web): add Mission Control frontend tests for phase 2 gate --- .../mission-control/BargeInInput.test.tsx | 155 +++++++++++++ .../GlobalAgentRoster.test.tsx | 203 ++++++++++++++++ .../MissionControlLayout.test.tsx | 70 ++++++ .../OrchestratorPanel.test.tsx | 218 ++++++++++++++++++ .../mission-control/PanelControls.test.tsx | 161 +++++++++++++ 5 files changed, 807 insertions(+) create mode 100644 apps/web/src/components/mission-control/BargeInInput.test.tsx create mode 100644 apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx create mode 100644 apps/web/src/components/mission-control/MissionControlLayout.test.tsx create mode 100644 apps/web/src/components/mission-control/OrchestratorPanel.test.tsx create mode 100644 apps/web/src/components/mission-control/PanelControls.test.tsx 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/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/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 => ( +