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

438 lines
14 KiB
TypeScript

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getMock, putMock, postMock } = vi.hoisted(() => ({
getMock: vi.fn(),
putMock: vi.fn(),
postMock: vi.fn(),
}));
vi.mock("../lib/api-client", () => ({
apiClient: {
GET: getMock,
PUT: putMock,
POST: postMock,
},
apiErrorMessage: (error: unknown) => {
if (error instanceof Error) return error.message;
if (typeof error === "object" && error !== null && "message" in error) {
return String((error as { message: unknown }).message);
}
return "Request failed";
},
}));
import { ProjectSettingsForm } from "./ProjectSettingsForm";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import type { WorkspaceSummary } from "../types/workspace";
function renderSettings(projectId = "proj-1", workspaces?: WorkspaceSummary[]) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
if (workspaces) {
queryClient.setQueryData(workspaceQueryKey, workspaces);
}
render(
<QueryClientProvider client={queryClient}>
<ProjectSettingsForm projectId={projectId} />
</QueryClientProvider>,
);
return queryClient;
}
async function chooseOption(trigger: HTMLElement, optionName: string) {
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole("option", { name: optionName }));
}
const agentCatalogResponse = {
data: {
supported: [
{ id: "claude-code", label: "Claude Code" },
{ id: "codex", label: "Codex" },
{ id: "goose", label: "Goose" },
{ id: "kiro", label: "Kiro" },
{ id: "opencode", label: "OpenCode" },
],
installed: [
{ id: "claude-code", label: "Claude Code", authStatus: "authorized" },
{ id: "codex", label: "Codex", authStatus: "authorized" },
{ id: "goose", label: "Goose", authStatus: "authorized" },
{ id: "kiro", label: "Kiro", authStatus: "unknown" },
{ id: "opencode", label: "OpenCode", authStatus: "authorized" },
],
authorized: [
{ id: "claude-code", label: "Claude Code", authStatus: "authorized" },
{ id: "codex", label: "Codex", authStatus: "authorized" },
{ id: "goose", label: "Goose", authStatus: "authorized" },
{ id: "opencode", label: "OpenCode", authStatus: "authorized" },
],
},
error: undefined,
};
function mockProject(project: Record<string, unknown>) {
getMock.mockImplementation(async (path: string) => {
if (path === "/api/v1/agents") return agentCatalogResponse;
return {
data: {
status: "ok",
project,
},
error: undefined,
};
});
}
beforeEach(() => {
getMock.mockReset();
putMock.mockReset();
postMock.mockReset();
putMock.mockResolvedValue({ data: { project: {} }, error: undefined });
postMock.mockResolvedValue({ data: { orchestrator: { id: "proj-1-orch-2" } }, error: undefined, response: { status: 200 } });
});
describe("ProjectSettingsForm", () => {
it("loads the current project settings and saves the exposed fields without dropping hidden config", async () => {
mockProject({
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "git@github.com:acme/project-one.git",
defaultBranch: "main",
config: {
defaultBranch: "develop",
sessionPrefix: "po",
env: { FOO: "bar" },
symlinks: [".env"],
postCreate: ["npm install"],
worker: {
agent: "codex",
agentConfig: { model: "worker-model" },
},
orchestrator: { agent: "claude-code" },
agentConfig: {
model: "claude-opus-4-5",
permissions: "auto",
},
reviewers: [{ harness: "claude-code" }],
},
});
renderSettings();
expect(await screen.findByText("git@github.com:acme/project-one.git")).toBeInTheDocument();
expect(screen.getByLabelText("Default branch")).toHaveValue("develop");
expect(screen.getByLabelText("Session prefix")).toHaveValue("po");
expect(screen.getByLabelText("Model override")).toHaveValue("claude-opus-4-5");
const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" });
const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" });
const permissionMode = screen.getByRole("combobox", { name: "Permission mode" });
const reviewerAgent = screen.getByRole("combobox", { name: "Default reviewer agent" });
expect(workerAgent).toHaveTextContent("codex");
expect(orchestratorAgent).toHaveTextContent("claude-code");
expect(permissionMode).toHaveTextContent("Auto");
expect(reviewerAgent).toHaveTextContent("claude-code");
await userEvent.clear(screen.getByLabelText("Default branch"));
await userEvent.type(screen.getByLabelText("Default branch"), "release");
await userEvent.clear(screen.getByLabelText("Session prefix"));
await userEvent.type(screen.getByLabelText("Session prefix"), "rel");
await userEvent.clear(screen.getByLabelText("Model override"));
await userEvent.type(screen.getByLabelText("Model override"), "gpt-5-codex");
await chooseOption(workerAgent, "OpenCode");
await chooseOption(orchestratorAgent, "Goose");
await chooseOption(permissionMode, "Bypass permissions");
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1));
expect(putMock).toHaveBeenCalledWith("/api/v1/projects/{id}/config", {
params: { path: { id: "proj-1" } },
body: {
config: {
defaultBranch: "release",
sessionPrefix: "rel",
env: { FOO: "bar" },
symlinks: [".env"],
postCreate: ["npm install"],
worker: {
agent: "opencode",
agentConfig: { model: "worker-model" },
},
orchestrator: { agent: "goose" },
agentConfig: {
model: "gpt-5-codex",
permissions: "bypass-permissions",
},
reviewers: [{ harness: "claude-code" }],
},
},
});
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", {
body: { projectId: "proj-1", clean: true },
});
expect(await screen.findByText("Saved.")).toBeInTheDocument();
}, 20_000);
it("shows the daemon validation message when save fails", async () => {
mockProject({
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "claude-code" },
},
});
putMock.mockResolvedValue({
data: undefined,
error: { message: "invalid permissions" },
});
renderSettings();
await userEvent.click(await screen.findByRole("button", { name: "Save changes" }));
expect(await screen.findByText("invalid permissions")).toBeInTheDocument();
expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
expect(postMock).not.toHaveBeenCalled();
});
it("requires worker and orchestrator agents for existing projects missing role config", async () => {
mockProject({
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {},
});
renderSettings();
expect(await screen.findByText("Worker and orchestrator agents are required.")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Default worker agent" })).toHaveTextContent("Select worker agent");
expect(screen.getByRole("combobox", { name: "Default orchestrator agent" })).toHaveTextContent(
"Select orchestrator agent",
);
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(await screen.findAllByText("Worker and orchestrator agents are required.")).toHaveLength(2);
expect(putMock).not.toHaveBeenCalled();
});
it("shows unknown-auth agents as selectable with a warning in project settings", async () => {
mockProject({
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "claude-code" },
},
});
renderSettings();
await waitFor(() => expect(screen.getAllByText("/repo/project-one").length).toBeGreaterThan(0));
const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" });
await userEvent.click(workerAgent);
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Claude Code",
"Codex",
"Goose",
"OpenCode",
"KiroAuth unknown",
]);
expect(options[4]).not.toHaveAttribute("aria-disabled", "true");
});
it("saves GitHub tracker intake settings, deriving the repo from the project's git origin", async () => {
getMock.mockResolvedValue({
data: {
status: "ok",
project: {
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "git@github.com:acme/project-one.git",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "claude-code" },
},
},
},
error: undefined,
});
renderSettings();
await userEvent.click(await screen.findByLabelText("Enable issue intake"));
// Repository is display-only, derived from the project's own git origin — no input to
// fill. Assignee is the only eligibility rule in v1.
expect(screen.getByRole("link", { name: "acme/project-one" })).toHaveAttribute(
"href",
"https://github.com/acme/project-one",
);
await userEvent.type(screen.getByLabelText("Assignee"), "octocat");
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1));
const body = putMock.mock.calls[0]?.[1]?.body;
expect(body.config.trackerIntake).toEqual({
enabled: true,
provider: "github",
assignee: "octocat",
});
});
it("blocks save when intake is enabled with no assignee", async () => {
getMock.mockResolvedValue({
data: {
status: "ok",
project: {
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "git@github.com:acme/project-one.git",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "claude-code" },
},
},
},
error: undefined,
});
renderSettings();
await userEvent.click(await screen.findByLabelText("Enable issue intake"));
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(await screen.findAllByText("Enabling intake requires an assignee.")).toHaveLength(2);
expect(putMock).not.toHaveBeenCalled();
});
it("restarts when the saved orchestrator agent already differs from the running orchestrator", async () => {
getMock.mockResolvedValue({
data: {
status: "ok",
project: {
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "goose" },
},
},
},
error: undefined,
});
renderSettings("proj-1", [
{
id: "proj-1",
name: "Project One",
path: "/repo/project-one",
orchestratorAgent: "goose",
sessions: [
{
id: "proj-1-orchestrator",
workspaceId: "proj-1",
workspaceName: "Project One",
title: "Orchestrator",
provider: "claude-code",
kind: "orchestrator",
branch: "ao/proj-1-orchestrator",
status: "working",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z",
prs: [],
},
],
},
]);
const orchestratorAgent = await screen.findByRole("combobox", { name: "Default orchestrator agent" });
expect(orchestratorAgent).toHaveTextContent("goose");
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", {
body: { projectId: "proj-1", clean: true },
});
});
it("keeps the config save successful when orchestrator replacement fails", async () => {
getMock.mockResolvedValue({
data: {
status: "ok",
project: {
id: "proj-1",
name: "Project One",
kind: "single_repo",
path: "/repo/project-one",
repo: "",
defaultBranch: "main",
config: {
worker: { agent: "codex" },
orchestrator: { agent: "claude-code" },
},
},
},
error: undefined,
});
postMock.mockResolvedValue({
data: undefined,
error: { message: "missing goose binary" },
response: { status: 500 },
});
const queryClient = renderSettings();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const orchestratorAgent = await screen.findByRole("combobox", { name: "Default orchestrator agent" });
await chooseOption(orchestratorAgent, "goose");
await userEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1));
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(await screen.findByText("Saved.")).toBeInTheDocument();
expect(await screen.findByText("Orchestrator restart failed: missing goose binary")).toBeInTheDocument();
expect(screen.queryByText("Save failed")).not.toBeInTheDocument();
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["project", "proj-1"] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
});
});