From 762277585df697fc14af41cc0a856405b9018a91 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 7 Mar 2026 15:37:11 -0600 Subject: [PATCH] test(web): add Mission Control phase 2 test suite --- .../__tests__/GlobalAgentRoster.test.tsx | 133 ++++++++++++++++++ .../__tests__/KillAllDialog.test.tsx | 96 +++++++++++++ .../__tests__/OrchestratorPanel.test.tsx | 93 ++++++++++++ .../__tests__/PanelControls.test.tsx | 70 +++++++++ .../mission-control-phase2.gate.test.tsx | 34 +++++ 5 files changed, 426 insertions(+) create mode 100644 apps/web/src/components/mission-control/__tests__/GlobalAgentRoster.test.tsx create mode 100644 apps/web/src/components/mission-control/__tests__/KillAllDialog.test.tsx create mode 100644 apps/web/src/components/mission-control/__tests__/OrchestratorPanel.test.tsx create mode 100644 apps/web/src/components/mission-control/__tests__/PanelControls.test.tsx create mode 100644 apps/web/src/components/mission-control/__tests__/mission-control-phase2.gate.test.tsx diff --git a/apps/web/src/components/mission-control/__tests__/GlobalAgentRoster.test.tsx b/apps/web/src/components/mission-control/__tests__/GlobalAgentRoster.test.tsx new file mode 100644 index 0000000..76f0fe8 --- /dev/null +++ b/apps/web/src/components/mission-control/__tests__/GlobalAgentRoster.test.tsx @@ -0,0 +1,133 @@ +import type { ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GlobalAgentRoster } from "../GlobalAgentRoster"; + +const { mockApiGet, mockApiPost } = vi.hoisted(() => ({ + mockApiGet: vi.fn(), + mockApiPost: vi.fn(), +})); + +vi.mock("@/lib/api/client", () => ({ + apiGet: mockApiGet, + apiPost: mockApiPost, +})); + +function renderWithQueryClient(ui: ReactElement): void { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + + render({ui}); +} + +describe("GlobalAgentRoster (__tests__)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + mockApiGet.mockReset(); + mockApiPost.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders empty state when no sessions", async () => { + mockApiGet.mockResolvedValueOnce([]); + + renderWithQueryClient(); + + expect(await screen.findByText("No active agents")).toBeInTheDocument(); + }); + + it("renders session rows grouped by provider", async () => { + mockApiGet.mockResolvedValueOnce([ + { + id: "sess-int-123456", + providerId: "internal", + providerType: "internal", + status: "active", + createdAt: "2026-03-07T19:00:00.000Z", + updatedAt: "2026-03-07T19:00:00.000Z", + }, + { + id: "sess-rem-654321", + providerId: "remote-a", + providerType: "remote", + status: "paused", + createdAt: "2026-03-07T19:00:00.000Z", + updatedAt: "2026-03-07T19:00:00.000Z", + }, + ]); + + renderWithQueryClient(); + + expect(await screen.findByText("internal")).toBeInTheDocument(); + expect(screen.getByText("remote-a (remote)")).toBeInTheDocument(); + expect(screen.getByText("sess-int")).toBeInTheDocument(); + expect(screen.getByText("sess-rem")).toBeInTheDocument(); + }); + + it("kill button per row calls the API", async () => { + const user = userEvent.setup(); + + mockApiGet.mockResolvedValueOnce([ + { + id: "killme123456", + providerId: "internal", + providerType: "internal", + status: "active", + createdAt: "2026-03-07T19:00:00.000Z", + updatedAt: "2026-03-07T19:00:00.000Z", + }, + ]); + + mockApiPost.mockResolvedValue({ message: "ok" }); + + renderWithQueryClient(); + + const killButton = await screen.findByRole("button", { name: "Kill session killme12" }); + await user.click(killButton); + + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", { + force: false, + }); + }); + }); + + it("onSelectSession callback fires on row click", async () => { + const user = userEvent.setup(); + const onSelectSession = vi.fn(); + + mockApiGet.mockResolvedValueOnce([ + { + id: "selectme123456", + providerId: "internal", + providerType: "internal", + status: "active", + createdAt: "2026-03-07T19:00:00.000Z", + updatedAt: "2026-03-07T19:00:00.000Z", + }, + ]); + + renderWithQueryClient(); + + const sessionLabel = await screen.findByText("selectme"); + const row = sessionLabel.closest('[role="button"]'); + + if (!row) { + throw new Error("Expected session row for selectme123456"); + } + + await user.click(row); + + expect(onSelectSession).toHaveBeenCalledWith("selectme123456"); + }); +}); diff --git a/apps/web/src/components/mission-control/__tests__/KillAllDialog.test.tsx b/apps/web/src/components/mission-control/__tests__/KillAllDialog.test.tsx new file mode 100644 index 0000000..fafb03e --- /dev/null +++ b/apps/web/src/components/mission-control/__tests__/KillAllDialog.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentSession } from "@mosaic/shared"; +import { KillAllDialog } from "../KillAllDialog"; +import * as apiClient from "@/lib/api/client"; + +vi.mock("@/lib/api/client", () => ({ + apiPost: vi.fn(), +})); + +const mockApiPost = vi.mocked(apiClient.apiPost); +const baseDate = new Date("2026-03-07T14:00:00.000Z"); + +const sessions: AgentSession[] = [ + { + id: "session-internal-1", + providerId: "provider-internal-1", + providerType: "internal", + status: "active", + createdAt: baseDate, + updatedAt: baseDate, + }, + { + id: "session-internal-2", + providerId: "provider-internal-2", + providerType: "internal", + status: "paused", + createdAt: baseDate, + updatedAt: baseDate, + }, + { + id: "session-external-1", + providerId: "provider-openclaw-1", + providerType: "openclaw", + status: "active", + createdAt: baseDate, + updatedAt: baseDate, + }, +]; + +describe("KillAllDialog (__tests__)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + mockApiPost.mockResolvedValue({ message: "killed" } as never); + }); + + it('Confirm button disabled until "KILL ALL" typed exactly', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("button", { name: "Kill All" })); + + const input = screen.getByLabelText("Type KILL ALL to confirm"); + const confirmButton = screen.getByRole("button", { name: "Kill All Agents" }); + + expect(confirmButton).toBeDisabled(); + + await user.type(input, "kill all"); + expect(confirmButton).toBeDisabled(); + + await user.clear(input); + await user.type(input, "KILL ALL"); + expect(confirmButton).toBeEnabled(); + }); + + it("fires kill API for each session on confirm", async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("button", { name: "Kill All" })); + await user.click(screen.getByLabelText("All providers (3)")); + await user.type(screen.getByLabelText("Type KILL ALL to confirm"), "KILL ALL"); + await user.click(screen.getByRole("button", { name: "Kill All Agents" })); + + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledTimes(3); + }); + + expect(mockApiPost).toHaveBeenCalledWith( + "/api/mission-control/sessions/session-internal-1/kill", + { force: true } + ); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/mission-control/sessions/session-internal-2/kill", + { force: true } + ); + expect(mockApiPost).toHaveBeenCalledWith( + "/api/mission-control/sessions/session-external-1/kill", + { force: true } + ); + }); +}); diff --git a/apps/web/src/components/mission-control/__tests__/OrchestratorPanel.test.tsx b/apps/web/src/components/mission-control/__tests__/OrchestratorPanel.test.tsx new file mode 100644 index 0000000..4b185ac --- /dev/null +++ b/apps/web/src/components/mission-control/__tests__/OrchestratorPanel.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { OrchestratorPanel } from "../OrchestratorPanel"; +import * as missionControlHooks from "@/hooks/useMissionControl"; + +vi.mock("@/hooks/useMissionControl", () => ({ + useSessionStream: vi.fn(), + useSessions: vi.fn(), +})); + +vi.mock("@/components/mission-control/PanelControls", () => ({ + PanelControls: (): React.JSX.Element =>
, +})); + +vi.mock("@/components/mission-control/BargeInInput", () => ({ + BargeInInput: ({ sessionId }: { sessionId: string }): React.JSX.Element => ( +
barge-in:{sessionId}
+ ), +})); + +vi.mock("date-fns", () => ({ + formatDistanceToNow: (): string => "moments ago", +})); + +const mockUseSessionStream = vi.mocked(missionControlHooks.useSessionStream); +const mockUseSessions = vi.mocked(missionControlHooks.useSessions); + +beforeAll(() => { + Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); +}); + +describe("OrchestratorPanel (__tests__)", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockUseSessionStream.mockReturnValue({ + messages: [], + status: "connected", + error: null, + }); + + mockUseSessions.mockReturnValue({ + sessions: [], + loading: false, + error: null, + }); + }); + + it("renders empty state when no sessionId", () => { + render(); + + expect(screen.getByText("Select an agent to view its stream")).toBeInTheDocument(); + }); + + it("renders connection indicator", () => { + const { container } = render(); + + expect(screen.getByText("Connected")).toBeInTheDocument(); + expect(container.querySelector(".bg-emerald-500")).toBeInTheDocument(); + }); + + it("renders message list when messages are present", () => { + mockUseSessionStream.mockReturnValue({ + messages: [ + { + id: "msg-1", + sessionId: "session-1", + role: "assistant", + content: "Mission update one", + timestamp: "2026-03-07T21:00:00.000Z", + }, + { + id: "msg-2", + sessionId: "session-1", + role: "tool", + content: "Mission update two", + timestamp: "2026-03-07T21:00:01.000Z", + }, + ], + status: "connected", + error: null, + }); + + render(); + + expect(screen.getByText("Mission update one")).toBeInTheDocument(); + expect(screen.getByText("Mission update two")).toBeInTheDocument(); + expect(screen.queryByText("Waiting for messages...")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/mission-control/__tests__/PanelControls.test.tsx b/apps/web/src/components/mission-control/__tests__/PanelControls.test.tsx new file mode 100644 index 0000000..5ac15db --- /dev/null +++ b/apps/web/src/components/mission-control/__tests__/PanelControls.test.tsx @@ -0,0 +1,70 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PanelControls } from "../PanelControls"; +import * as apiClient from "@/lib/api/client"; + +vi.mock("@/lib/api/client", () => ({ + apiPost: vi.fn(), +})); + +const mockApiPost = vi.mocked(apiClient.apiPost); + +function renderPanelControls(status: string): void { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + render( + + + + ); +} + +describe("PanelControls (__tests__)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + mockApiPost.mockResolvedValue({ message: "ok" } as never); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("Pause button disabled when status=paused", () => { + renderPanelControls("paused"); + + expect(screen.getByRole("button", { name: "Pause session" })).toBeDisabled(); + }); + + it("Resume button disabled when status=active", () => { + renderPanelControls("active"); + + expect(screen.getByRole("button", { name: "Resume session" })).toBeDisabled(); + }); + + it("Kill buttons disabled when status=killed", () => { + renderPanelControls("killed"); + + expect(screen.getByRole("button", { name: "Gracefully kill session" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Force kill session" })).toBeDisabled(); + }); + + it("clicking pause calls the API", async () => { + const user = userEvent.setup(); + + renderPanelControls("active"); + + await user.click(screen.getByRole("button", { name: "Pause session" })); + + await waitFor(() => { + expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/pause"); + }); + }); +}); diff --git a/apps/web/src/components/mission-control/__tests__/mission-control-phase2.gate.test.tsx b/apps/web/src/components/mission-control/__tests__/mission-control-phase2.gate.test.tsx new file mode 100644 index 0000000..0a83d06 --- /dev/null +++ b/apps/web/src/components/mission-control/__tests__/mission-control-phase2.gate.test.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MissionControlLayout } from "../MissionControlLayout"; + +vi.mock("@/components/mission-control/AuditLogDrawer", () => ({ + AuditLogDrawer: ({ trigger }: { trigger: ReactNode }): React.JSX.Element => ( +
{trigger}
+ ), +})); + +vi.mock("@/components/mission-control/GlobalAgentRoster", () => ({ + GlobalAgentRoster: (): React.JSX.Element =>
, +})); + +vi.mock("@/components/mission-control/MissionControlPanel", () => ({ + MissionControlPanel: (): React.JSX.Element =>
, + MIN_PANEL_COUNT: 1, + MAX_PANEL_COUNT: 6, +})); + +describe("Mission Control Phase 2 Gate", () => { + it("Phase 2 gate: MissionControlLayout renders with all components present", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation((..._args) => undefined); + + render(); + + expect(screen.getByTestId("global-agent-roster")).toBeInTheDocument(); + expect(screen.getByTestId("mission-control-panel")).toBeInTheDocument(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); +}); -- 2.49.1