test(web): MS23-P2-009 Mission Control frontend tests
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:
205
apps/web/src/components/mission-control/AuditLogDrawer.test.tsx
Normal file
205
apps/web/src/components/mission-control/AuditLogDrawer.test.tsx
Normal file
@@ -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<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface MockBadgeProps extends HTMLAttributes<HTMLElement> {
|
||||
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<AuditLogResponse>>();
|
||||
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
apiGet: (endpoint: string): Promise<AuditLogResponse> => mockApiGet(endpoint),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, ...props }: MockButtonProps): React.JSX.Element => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/badge", () => ({
|
||||
Badge: ({ children, ...props }: MockBadgeProps): React.JSX.Element => (
|
||||
<span {...props}>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
import { AuditLogDrawer } from "./AuditLogDrawer";
|
||||
|
||||
function renderWithQueryClient(ui: React.JSX.Element): ReturnType<typeof render> {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
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<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithQueryClient(<AuditLogDrawer trigger="Audit" />);
|
||||
|
||||
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<void> => {
|
||||
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(<AuditLogDrawer trigger="Audit" />);
|
||||
|
||||
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<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockApiGet.mockImplementation((endpoint: string): Promise<AuditLogResponse> => {
|
||||
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(<AuditLogDrawer trigger="Audit" />);
|
||||
|
||||
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<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithQueryClient(<AuditLogDrawer trigger="Audit" sessionId="session 7" />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
170
apps/web/src/components/mission-control/KillAllDialog.test.tsx
Normal file
170
apps/web/src/components/mission-control/KillAllDialog.test.tsx
Normal file
@@ -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<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface MockInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface MockLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||
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 => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/input", () => ({
|
||||
Input: ({ ...props }: MockInputProps): React.JSX.Element => <input {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/label", () => ({
|
||||
Label: ({ children, ...props }: MockLabelProps): React.JSX.Element => (
|
||||
<label {...props}>{children}</label>
|
||||
),
|
||||
}));
|
||||
|
||||
import { KillAllDialog } from "./KillAllDialog";
|
||||
|
||||
function makeSession(overrides: Partial<MockSession>): 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<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<KillAllDialog sessions={[makeSession({})]} />);
|
||||
|
||||
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<void> => {
|
||||
const onComplete = vi.fn<() => void>();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<KillAllDialog
|
||||
sessions={[
|
||||
makeSession({ id: "internal-1", providerType: "internal" }),
|
||||
makeSession({ id: "external-1", providerType: "external" }),
|
||||
]}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
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<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<KillAllDialog
|
||||
sessions={[
|
||||
makeSession({ id: "internal-2", providerType: "internal" }),
|
||||
makeSession({ id: "external-2", providerType: "external" }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
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<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<KillAllDialog
|
||||
sessions={[
|
||||
makeSession({ id: "external-only", providerId: "ext", providerType: "external" }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLButtonElement> {
|
||||
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 => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
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 => (
|
||||
<div
|
||||
data-testid="orchestrator-panel"
|
||||
data-session-id={sessionId ?? ""}
|
||||
data-close-disabled={String(closeDisabled ?? false)}
|
||||
data-expanded={String(expanded ?? false)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the panel grid and default heading", (): void => {
|
||||
render(
|
||||
<MissionControlPanel
|
||||
panels={[{}]}
|
||||
onAddPanel={vi.fn<() => 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(
|
||||
<MissionControlPanel
|
||||
panels={[{}]}
|
||||
onAddPanel={onAddPanel}
|
||||
onRemovePanel={vi.fn<(index: number) => 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(
|
||||
<MissionControlPanel
|
||||
panels={buildPanels(MAX_PANEL_COUNT)}
|
||||
onAddPanel={vi.fn<() => 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(
|
||||
<MissionControlPanel
|
||||
panels={buildPanels(2)}
|
||||
onAddPanel={vi.fn<() => 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(
|
||||
<MissionControlPanel
|
||||
panels={[{ sessionId: "session-1" }, { sessionId: "session-2", expanded: true }]}
|
||||
onAddPanel={vi.fn<() => 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<void> => {
|
||||
const onExpandPanel = vi.fn<(index: number) => void>();
|
||||
|
||||
render(
|
||||
<MissionControlPanel
|
||||
panels={[{ sessionId: "session-1", expanded: true }, { sessionId: "session-2" }]}
|
||||
onAddPanel={vi.fn<() => void>()}
|
||||
onRemovePanel={vi.fn<(index: number) => void>()}
|
||||
onExpandPanel={onExpandPanel}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(onExpandPanel).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user