162 lines
5.4 KiB
TypeScript
162 lines
5.4 KiB
TypeScript
import { afterEach, 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;
|
|
}
|
|
|
|
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/badge", () => ({
|
|
Badge: ({ children, ...props }: MockBadgeProps): React.JSX.Element => (
|
|
<span {...props}>{children}</span>
|
|
),
|
|
}));
|
|
|
|
import { PanelControls } from "./PanelControls";
|
|
|
|
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>);
|
|
}
|
|
|
|
describe("PanelControls", (): void => {
|
|
beforeEach((): void => {
|
|
vi.clearAllMocks();
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
mockApiPost.mockResolvedValue({ message: "ok" });
|
|
});
|
|
|
|
afterEach((): void => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("renders action buttons with correct disabled state for active sessions", (): void => {
|
|
renderWithQueryClient(<PanelControls sessionId="session-1" status="active" />);
|
|
|
|
expect(screen.getByRole("button", { name: "Pause session" })).toBeEnabled();
|
|
expect(screen.getByRole("button", { name: "Resume session" })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: "Gracefully kill session" })).toBeEnabled();
|
|
expect(screen.getByRole("button", { name: "Force kill session" })).toBeEnabled();
|
|
});
|
|
|
|
it("disables all action buttons when session is already killed", (): void => {
|
|
renderWithQueryClient(<PanelControls sessionId="session-2" status="killed" />);
|
|
|
|
expect(screen.getByRole("button", { name: "Pause session" })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: "Resume session" })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: "Gracefully kill session" })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: "Force kill session" })).toBeDisabled();
|
|
});
|
|
|
|
it("pauses a running session and reports the next status", async (): Promise<void> => {
|
|
const onStatusChange = vi.fn<(status: string) => void>();
|
|
const user = userEvent.setup();
|
|
|
|
renderWithQueryClient(
|
|
<PanelControls
|
|
sessionId="session with space"
|
|
status="active"
|
|
onStatusChange={onStatusChange}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Pause session" }));
|
|
|
|
await waitFor((): void => {
|
|
expect(mockApiPost).toHaveBeenCalledWith(
|
|
"/api/mission-control/sessions/session%20with%20space/pause",
|
|
undefined
|
|
);
|
|
});
|
|
|
|
expect(onStatusChange).toHaveBeenCalledWith("paused");
|
|
});
|
|
|
|
it("asks for graceful kill confirmation before submitting", async (): Promise<void> => {
|
|
const onStatusChange = vi.fn<(status: string) => void>();
|
|
const user = userEvent.setup();
|
|
|
|
renderWithQueryClient(
|
|
<PanelControls sessionId="session-4" status="active" onStatusChange={onStatusChange} />
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Gracefully kill session" }));
|
|
|
|
expect(
|
|
screen.getByText("Gracefully stop this agent after it finishes the current step?")
|
|
).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
|
|
|
await waitFor((): void => {
|
|
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-4/kill", {
|
|
force: false,
|
|
});
|
|
});
|
|
|
|
expect(onStatusChange).toHaveBeenCalledWith("killed");
|
|
});
|
|
|
|
it("sends force kill after confirmation", async (): Promise<void> => {
|
|
const onStatusChange = vi.fn<(status: string) => void>();
|
|
const user = userEvent.setup();
|
|
|
|
renderWithQueryClient(
|
|
<PanelControls sessionId="session-5" status="paused" onStatusChange={onStatusChange} />
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Force kill session" }));
|
|
|
|
expect(screen.getByText("This will hard-kill the agent immediately.")).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
|
|
|
await waitFor((): void => {
|
|
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-5/kill", {
|
|
force: true,
|
|
});
|
|
});
|
|
|
|
expect(onStatusChange).toHaveBeenCalledWith("killed");
|
|
});
|
|
|
|
it("shows an error badge when an action fails", async (): Promise<void> => {
|
|
const user = userEvent.setup();
|
|
|
|
mockApiPost.mockRejectedValueOnce(new Error("unable to pause"));
|
|
|
|
renderWithQueryClient(<PanelControls sessionId="session-6" status="active" />);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Pause session" }));
|
|
|
|
await waitFor((): void => {
|
|
expect(screen.getByText("unable to pause")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|