agent-orchestrator/frontend/src/renderer/components/Sidebar.test.tsx

454 lines
17 KiB
TypeScript

import { SidebarProvider } from "@/components/ui/sidebar";
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 { Sidebar } from "./Sidebar";
import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace";
import { agentsQueryKey } from "../hooks/useAgentsQuery";
const { getMock, navigateMock, mockParams, renameSessionMock } = vi.hoisted(() => ({
getMock: vi.fn(),
navigateMock: vi.fn(),
mockParams: { projectId: undefined as string | undefined },
renameSessionMock: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/rename-session", () => ({ renameSession: renameSessionMock }));
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: () => navigateMock,
useParams: () => ({}),
useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) =>
select({ location: { pathname: "/" } }),
};
});
vi.mock("../lib/api-client", () => ({
apiClient: { GET: getMock },
apiErrorMessage: (error: unknown) => {
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message;
}
return "Request failed";
},
}));
const workspace: WorkspaceSummary = {
id: "proj-1",
name: "Project One",
path: "/repo/project-one",
sessions: [],
};
const session: WorkspaceSession = {
id: "proj-1-1",
workspaceId: "proj-1",
workspaceName: "Project One",
title: "fix login",
provider: "claude-code",
kind: "worker",
branch: "session/proj-1-1",
status: "working",
updatedAt: "2026-06-30T00:00:00Z",
prs: [],
};
type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
type RemoveProjectHandler = (projectId: string) => Promise<void>;
function renderSidebar({
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
seedAgents = true,
workspaces = [workspace],
}: {
onCreateProject?: CreateProjectHandler;
onRemoveProject?: RemoveProjectHandler;
seedAgents?: boolean;
workspaces?: WorkspaceSummary[];
} = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
if (seedAgents) {
queryClient.setQueryData(agentsQueryKey, {
supported: [
{ id: "claude-code", label: "Claude Code" },
{ id: "codex", label: "Codex" },
],
installed: [
{ id: "claude-code", label: "Claude Code" },
{ id: "codex", label: "Codex" },
],
authorized: [
{ id: "claude-code", label: "Claude Code", authStatus: "authorized" },
{ id: "codex", label: "Codex", authStatus: "authorized" },
],
});
}
render(
<QueryClientProvider client={queryClient}>
<SidebarProvider>
<Sidebar
daemonStatus={{ state: "running" }}
onCreateProject={onCreateProject}
onRemoveProject={onRemoveProject}
workspaces={workspaces}
/>
</SidebarProvider>
</QueryClientProvider>,
);
return onRemoveProject;
}
async function chooseOption(trigger: HTMLElement, optionName: string) {
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole("option", { name: optionName }));
}
beforeEach(() => {
getMock.mockReset();
getMock.mockResolvedValue({
data: {
supported: [
{ id: "claude-code", label: "Claude Code" },
{ id: "codex", label: "Codex" },
],
installed: [
{ id: "claude-code", label: "Claude Code" },
{ id: "codex", label: "Codex" },
],
authorized: [
{ id: "claude-code", label: "Claude Code", authStatus: "authorized" },
{ id: "codex", label: "Codex", authStatus: "authorized" },
],
},
error: undefined,
});
navigateMock.mockReset();
renameSessionMock.mockReset().mockResolvedValue(undefined);
mockParams.projectId = undefined;
vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window, "alert").mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Sidebar", () => {
it("confirms project removal before calling the remove handler", async () => {
const user = userEvent.setup();
const onRemoveProject = renderSidebar();
await user.click(screen.getByLabelText("Project actions for Project One"));
await user.click(await screen.findByRole("menuitem", { name: "Remove project" }));
expect(window.confirm).toHaveBeenCalledWith(
"Remove project Project One? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.",
);
await waitFor(() => expect(onRemoveProject).toHaveBeenCalledTimes(1));
});
it("does not remove the project when confirmation is cancelled", async () => {
vi.mocked(window.confirm).mockReturnValue(false);
const user = userEvent.setup();
const onRemoveProject = renderSidebar();
await user.click(screen.getByLabelText("Project actions for Project One"));
await user.click(await screen.findByRole("menuitem", { name: "Remove project" }));
expect(onRemoveProject).not.toHaveBeenCalled();
});
it("reveals dashboard and orchestrator buttons alongside the kebab on the project row", () => {
renderSidebar();
expect(screen.getByLabelText("Open Project One dashboard")).toBeInTheDocument();
expect(screen.getByLabelText("Spawn Project One orchestrator")).toBeInTheDocument();
expect(screen.getByLabelText("Project actions for Project One")).toBeInTheDocument();
});
it("navigates to the project board when the dashboard button is clicked", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByLabelText("Open Project One dashboard"));
expect(navigateMock).toHaveBeenCalledWith({ to: "/projects/$projectId", params: { projectId: "proj-1" } });
});
it("requires explicit worker and orchestrator agents when creating a project", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project");
renderSidebar({ onCreateProject });
await user.click(screen.getByLabelText("New project"));
expect(await screen.findByText("/repo/new-project")).toBeInTheDocument();
const dialog = screen.getByRole("dialog", { name: "Project agents" });
expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2");
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex");
await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code");
await user.click(screen.getByRole("button", { name: "Create and start" }));
await waitFor(() =>
expect(onCreateProject).toHaveBeenCalledWith({
path: "/repo/new-project",
workerAgent: "codex",
orchestratorAgent: "claude-code",
}),
);
});
it("shows needs-auth agents as unavailable while keeping authorized agents selectable", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project");
getMock.mockResolvedValueOnce({
data: {
supported: [
{ id: "claude-code", label: "Claude Code" },
{ id: "cursor", label: "Cursor" },
{ id: "aider", label: "Aider" },
],
installed: [
{ id: "claude-code", label: "Claude Code", authStatus: "authorized" },
{ id: "cursor", label: "Cursor", authStatus: "unauthorized" },
],
authorized: [{ id: "claude-code", label: "Claude Code", authStatus: "authorized" }],
},
error: undefined,
});
renderSidebar({ onCreateProject, seedAgents: false });
await user.click(screen.getByLabelText("New project"));
expect(await screen.findByText("/repo/new-project")).toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "Worker agent" }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Claude Code",
"CursorNeeds auth",
"AiderNeeds install",
]);
expect(options[1]).toHaveAttribute("aria-disabled", "true");
expect(options[2]).toHaveAttribute("aria-disabled", "true");
await user.keyboard("{Escape}");
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Claude Code");
await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code");
await user.click(screen.getByRole("button", { name: "Create and start" }));
await waitFor(() =>
expect(onCreateProject).toHaveBeenCalledWith(expect.objectContaining({ workerAgent: "claude-code" })),
);
});
it("updates project agent options when the catalog loads after the dialog opens", async () => {
const user = userEvent.setup();
const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler;
window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project");
let resolveAgents!: (value: {
data: {
supported: { id: string; label: string }[];
installed: { id: string; label: string }[];
authorized: { id: string; label: string; authStatus: "authorized" }[];
};
error: undefined;
}) => void;
getMock.mockReturnValueOnce(
new Promise((resolve) => {
resolveAgents = resolve;
}),
);
renderSidebar({ onCreateProject, seedAgents: false });
await user.click(screen.getByLabelText("New project"));
expect(await screen.findByText("/repo/new-project")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create and start" })).toBeDisabled();
resolveAgents({
data: {
supported: [
{ id: "claude-code", label: "Claude Code" },
{ id: "codex", label: "Codex" },
],
installed: [
{ id: "claude-code", label: "Claude Code" },
{ id: "codex", label: "Codex" },
],
authorized: [
{ id: "claude-code", label: "Claude Code", authStatus: "authorized" },
{ id: "codex", label: "Codex", authStatus: "authorized" },
],
},
error: undefined,
});
await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex");
await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code");
await user.click(screen.getByRole("button", { name: "Create and start" }));
await waitFor(() =>
expect(onCreateProject).toHaveBeenCalledWith({
path: "/repo/new-project",
workerAgent: "codex",
orchestratorAgent: "claude-code",
}),
);
});
it("opens feedback above Settings and copies redacted report drafts", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
const open = vi.spyOn(window, "open").mockReturnValue(null);
window.ao!.clipboard.writeText = writeText;
window.ao!.app.getVersion = vi.fn().mockResolvedValue("9.9.9-test");
window.ao!.daemon.getStatus = vi.fn().mockResolvedValue({
state: "ready",
message: "Listening at http://127.0.0.1:31001?token=secret",
});
renderSidebar();
const feedbackButton = screen.getAllByRole("button", { name: "Feedback" })[0];
const settingsButton = screen.getAllByRole("button", { name: "Settings" })[0];
expect(feedbackButton.compareDocumentPosition(settingsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await user.click(feedbackButton);
expect(await screen.findByRole("dialog", { name: "Report a problem" })).toBeInTheDocument();
await user.type(screen.getByLabelText("Summary"), "Create project fails in /Users/alice/private-repo");
await user.type(
screen.getByLabelText("Details"),
"Open http://127.0.0.1:5173/projects/demo?access_token=local-secret and click Create. Show a clear prerequisite error.",
);
expect(screen.queryByLabelText("Expected behavior")).not.toBeInTheDocument();
expect(screen.getByLabelText("Report preview")).toHaveTextContent("[redacted-local-path]");
await user.click(screen.getByRole("button", { name: "Copy and open GitHub" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const copied = writeText.mock.calls[0][0] as string;
expect(copied).toContain("Create project fails");
expect(copied).toContain("AO version: 9.9.9-test");
expect(copied).toContain("Daemon: ready");
expect(copied).toContain("[redacted-local-path]");
expect(copied).toContain("[redacted-local-url]");
expect(copied).not.toContain("/Users/alice");
expect(copied).not.toContain("local-secret");
expect(open).toHaveBeenCalledWith(
expect.stringContaining("https://github.com/AgentWrapper/agent-orchestrator/issues/new"),
"_blank",
"noopener,noreferrer",
);
});
it("copies Discord with an official invite and keeps email draft copy-only when no support address exists", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
const open = vi.spyOn(window, "open").mockReturnValue(null);
window.ao!.clipboard.writeText = writeText;
window.ao!.app.getVersion = vi.fn().mockRejectedValue(new Error("version unavailable"));
window.ao!.daemon.getStatus = vi.fn().mockRejectedValue(new Error("daemon unavailable"));
renderSidebar();
await user.click(screen.getAllByRole("button", { name: "Feedback" })[0]);
expect(await screen.findByRole("dialog", { name: "Report a problem" })).toBeInTheDocument();
await user.type(screen.getByLabelText("Summary"), "Need help with setup");
await user.click(screen.getByRole("button", { name: "Copy and open Discord" }));
await user.click(screen.getByRole("button", { name: "Copy email draft" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2));
expect(writeText.mock.calls[0][0]).toContain("Daemon: unknown");
expect(writeText.mock.calls[1][0]).toContain("AO feedback");
expect(open).toHaveBeenCalledOnce();
expect(open).toHaveBeenCalledWith("https://discord.com/invite/UZv7JjxbwG", "_blank", "noopener,noreferrer");
});
it("keeps the report form to summary and details while tailoring placeholder guidance", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getAllByRole("button", { name: "Feedback" })[0]);
expect(await screen.findByRole("dialog", { name: "Report a problem" })).toBeInTheDocument();
expect(screen.getByLabelText("Summary")).toHaveAttribute("placeholder", "Brief title");
expect(screen.getByLabelText("Details")).toHaveAttribute(
"placeholder",
"What happened, how to reproduce it, and what you expected.",
);
expect(screen.queryByLabelText("Expected behavior")).not.toBeInTheDocument();
await chooseOption(screen.getByRole("combobox", { name: "Report type" }), "Feature request");
expect(screen.getByLabelText("Summary")).toHaveAttribute("placeholder", "Brief title");
expect(screen.getByLabelText("Details")).toHaveAttribute(
"placeholder",
"The problem, use case, and requested behavior.",
);
await chooseOption(screen.getByRole("combobox", { name: "Report type" }), "General feedback");
expect(screen.getByLabelText("Summary")).toHaveAttribute("placeholder", "Brief title");
expect(screen.getByLabelText("Details")).toHaveAttribute("placeholder", "What should the AO team know?");
await chooseOption(screen.getByRole("combobox", { name: "Report type" }), "Setup question");
expect(screen.getByLabelText("Summary")).toHaveAttribute("placeholder", "Brief title");
expect(screen.getByLabelText("Details")).toHaveAttribute(
"placeholder",
"What you are trying to do, what you tried, and any error/output.",
);
});
it("renames a session inline and persists via the daemon", async () => {
const user = userEvent.setup();
const workspaceWithSession = { ...workspace, sessions: [session] };
renderSidebar({ workspaces: [workspaceWithSession] });
await user.click(screen.getByLabelText("Rename fix login"));
const input = screen.getByLabelText("Rename fix login");
await user.clear(input);
await user.type(input, "polish login{Enter}");
await waitFor(() => expect(renameSessionMock).toHaveBeenCalledWith("proj-1-1", "polish login"));
});
it("caps the inline rename input at 20 characters", async () => {
const user = userEvent.setup();
const workspaceWithSession = { ...workspace, sessions: [session] };
renderSidebar({ workspaces: [workspaceWithSession] });
await user.click(screen.getByLabelText("Rename fix login"));
expect(screen.getByLabelText("Rename fix login")).toHaveAttribute("maxlength", "20");
});
it("cancels the inline rename on Escape without calling the daemon", async () => {
const user = userEvent.setup();
const workspaceWithSession = { ...workspace, sessions: [session] };
renderSidebar({ workspaces: [workspaceWithSession] });
await user.click(screen.getByLabelText("Rename fix login"));
const input = screen.getByLabelText("Rename fix login");
await user.clear(input);
await user.type(input, "discard me{Escape}");
expect(renameSessionMock).not.toHaveBeenCalled();
expect(screen.getByLabelText("Open fix login")).toBeInTheDocument();
});
it("always shows action icons and reserves padding for them", () => {
renderSidebar();
const projectRow = screen.getByText("Project One").closest("button");
if (!projectRow) throw new Error("Project row button not found");
// Padding is always reserved for the action cluster (not hover-gated)
expect(projectRow).toHaveClass("pr-[84px]");
});
});