Files
fusion/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx
Dustin Byrne 3f85f28c23 feat(KB-184): change autoMerge default from false to true
- Update DEFAULT_SETTINGS in core types to set autoMerge: true
- Update initial state defaults in App and SettingsModal components
- Update test fixtures in App, Board, and SettingsModal tests
- Update kb-board skill docs to reflect new default
- Add minor changeset for the default change
2026-03-28 18:14:16 -04:00

643 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SettingsModal } from "../SettingsModal";
import type { Settings } from "@kb/core";
const defaultSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
recycleWorktrees: false,
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
};
vi.mock("../../api", () => ({
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve([
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
])),
}));
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../../api";
const onClose = vi.fn();
const addToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe("SettingsModal", () => {
it("renders all sidebar section labels", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Each label appears in the sidebar nav
expect(screen.getAllByText("General").length).toBeGreaterThanOrEqual(1);
const nav = screen.getAllByText("Scheduling");
expect(nav.length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Worktrees").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Commands").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Merge").length).toBeGreaterThanOrEqual(1);
});
it("shows General fields by default", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
// Fields from other sections should not be visible
expect(screen.queryByLabelText("Max Concurrent Tasks")).toBeNull();
expect(screen.queryByLabelText("Max Worktrees")).toBeNull();
});
it("switches section when clicking sidebar item", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click Scheduling
fireEvent.click(screen.getByText("Scheduling"));
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
// Click Commands
fireEvent.click(screen.getByText("Commands"));
expect(screen.getByLabelText("Test Command")).toBeTruthy();
expect(screen.getByLabelText("Build Command")).toBeTruthy();
expect(screen.queryByLabelText("Max Concurrent Tasks")).toBeNull();
});
it("all settings fields are present across all sections", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// General (default)
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
// Scheduling
fireEvent.click(screen.getByText("Scheduling"));
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.getByLabelText("Poll Interval (ms)")).toBeTruthy();
// Worktrees
fireEvent.click(screen.getByText("Worktrees"));
expect(screen.getByLabelText("Max Worktrees")).toBeTruthy();
expect(screen.getByLabelText("Worktree Init Command")).toBeTruthy();
expect(screen.getByText("Recycle worktrees")).toBeTruthy();
// Commands
fireEvent.click(screen.getByText("Commands"));
expect(screen.getByLabelText("Test Command")).toBeTruthy();
expect(screen.getByLabelText("Build Command")).toBeTruthy();
// Merge
fireEvent.click(screen.getByText("Merge"));
expect(screen.getByText("Auto-merge completed tasks")).toBeTruthy();
expect(screen.getByText("Include task ID in commit scope")).toBeTruthy();
});
it("shows Recycle worktrees checkbox in Worktrees section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Worktrees"));
const checkbox = screen.getByLabelText("Recycle worktrees");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
});
it("toggling recycleWorktrees checkbox sends true in save payload", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Worktrees"));
const checkbox = screen.getByLabelText("Recycle worktrees");
fireEvent.click(checkbox);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.recycleWorktrees).toBe(true);
});
it("Task Prefix field saves correctly when set", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "PROJ" } });
expect(input.value).toBe("PROJ");
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.taskPrefix).toBe("PROJ");
});
it("Task Prefix field submits undefined when empty (uses default)", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.taskPrefix).toBeUndefined();
});
it("Task Prefix shows validation error for invalid input", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "bad" } });
expect(screen.getByText("Prefix must be 110 uppercase letters")).toBeTruthy();
});
it("Task Prefix validation error prevents save", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "bad" } });
fireEvent.click(screen.getByText("Save"));
// Should not have called updateSettings due to validation error
expect(updateSettings).not.toHaveBeenCalled();
});
it("shows Include task ID in commit scope checkbox in Merge section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Merge"));
const checkbox = screen.getByLabelText("Include task ID in commit scope");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
});
it("toggling includeTaskIdInCommit checkbox sends false in save payload", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Merge"));
const checkbox = screen.getByLabelText("Include task ID in commit scope");
// Default is checked (true), click to uncheck
fireEvent.click(checkbox);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.includeTaskIdInCommit).toBe(false);
});
it("groupOverlappingFiles input has type checkbox", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
const checkbox = screen.getByLabelText("Serialize tasks with overlapping files");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
});
it("save button calls updateSettings with form data", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.maxConcurrent).toBe(2);
expect(payload.pollIntervalMs).toBe(15000);
});
it("shows Model in sidebar", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getAllByText("Model").length).toBeGreaterThanOrEqual(1);
});
it("shows model selector with available models grouped by provider", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
expect(screen.getByLabelText("Default Model")).toBeTruthy();
expect(screen.getByText("Claude Sonnet 4.5")).toBeTruthy();
expect(screen.getByText("GPT-4o")).toBeTruthy();
expect(screen.getByText("Use default")).toBeTruthy();
});
it("selecting a model updates form with provider and model ID", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const select = screen.getByLabelText("Default Model") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "anthropic/claude-sonnet-4-5" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.defaultProvider).toBe("anthropic");
expect(payload.defaultModelId).toBe("claude-sonnet-4-5");
});
it("Use default option clears model selection", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const select = screen.getByLabelText("Default Model") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.defaultProvider).toBeUndefined();
expect(payload.defaultModelId).toBeUndefined();
});
it("shows empty state when no models available", async () => {
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
expect(screen.getByText("No models available. Configure authentication first.")).toBeTruthy();
});
it("shows Authentication in sidebar", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getAllByText("Authentication").length).toBeGreaterThanOrEqual(1);
});
it("shows provider auth status when Authentication section is selected", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
expect(screen.getByText("Anthropic")).toBeTruthy();
expect(screen.getByText("✗ Not authenticated")).toBeTruthy();
});
it("shows authenticated status with checkmark", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: true }],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
expect(screen.getByText("✓ Authenticated")).toBeTruthy();
expect(screen.getByText("Logout")).toBeTruthy();
});
it("Login button calls loginProvider and opens URL", async () => {
const openSpy = vi.fn();
vi.stubGlobal("open", openSpy);
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
fireEvent.click(screen.getByText("Login"));
await waitFor(() => expect(loginProvider).toHaveBeenCalledWith("anthropic"));
expect(openSpy).toHaveBeenCalledWith("https://auth.example.com/login", "_blank");
vi.unstubAllGlobals();
});
it("Logout button calls logoutProvider and refreshes status", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: true }],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
fireEvent.click(screen.getByText("Logout"));
await waitFor(() => expect(logoutProvider).toHaveBeenCalledWith("anthropic"));
// Should refresh auth status after logout
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalledTimes(2));
expect(addToast).toHaveBeenCalledWith("Logged out", "success");
});
it("auth status badges have proper class names", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "github", name: "GitHub", authenticated: false },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const authBadge = screen.getByTestId("auth-status-anthropic");
expect(authBadge.className).toContain("auth-status-badge");
expect(authBadge.className).toContain("authenticated");
const unauthBadge = screen.getByTestId("auth-status-github");
expect(unauthBadge.className).toContain("auth-status-badge");
expect(unauthBadge.className).toContain("not-authenticated");
});
it("auth provider rows use auth-provider-row class", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const providerRow = screen.getByText("Anthropic").closest(".auth-provider-row");
expect(providerRow).toBeTruthy();
});
it("model section renders select element", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const select = screen.getByLabelText("Default Model") as HTMLSelectElement;
expect(select.tagName).toBe("SELECT");
});
it("checkbox labels use checkbox-label class", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
const label = screen.getByText("Serialize tasks with overlapping files");
expect(label.className).toContain("checkbox-label");
});
it("no inline style attributes remain on SettingsModal elements", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Check that no elements in the settings content have inline styles
const elementsWithStyle = container.querySelectorAll("[style]");
expect(elementsWithStyle.length).toBe(0);
});
it("shows Thinking Effort dropdown with correct options in Model section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const select = screen.getByLabelText("Thinking Effort") as HTMLSelectElement;
expect(select.tagName).toBe("SELECT");
const options = Array.from(select.options).map((o) => o.textContent);
expect(options).toEqual(["Default", "Off", "Minimal", "Low", "Medium", "High"]);
});
it("changing Thinking Effort dropdown updates form and is included in save payload", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
const select = screen.getByLabelText("Thinking Effort") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "high" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.defaultThinkingLevel).toBe("high");
});
it("Thinking Effort dropdown is hidden when selected model does not support reasoning", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
defaultProvider: "openai",
defaultModelId: "gpt-4o",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
expect(screen.queryByLabelText("Thinking Effort")).toBeNull();
});
it("shows loading state during login", async () => {
// Make loginProvider hang
(loginProvider as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
vi.stubGlobal("open", vi.fn());
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
fireEvent.click(screen.getByText("Login"));
// While login is in progress, button should show waiting state
// (loginProvider hasn't resolved yet so we can't waitFor it)
// The button will be disabled during the async operation
vi.unstubAllGlobals();
});
it("opens to Authentication section when initialSection='authentication' is passed", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} initialSection="authentication" />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// Authentication content should be visible immediately
expect(screen.getByText("Anthropic")).toBeTruthy();
// General content should NOT be visible
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
});
it("defaults to General section when no initialSection is passed", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// General content should be visible
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
// Authentication content should NOT be visible
expect(screen.queryByText("✗ Not authenticated")).toBeNull();
});
it("shows sign-in hint when no providers are authenticated", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false },
{ id: "github", name: "GitHub", authenticated: false },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
expect(screen.getByText("Sign in to at least one provider to get started.")).toBeTruthy();
// Provider rows should still be visible
expect(screen.getByText("Anthropic")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
});
it("hides sign-in hint when at least one provider is authenticated", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "github", name: "GitHub", authenticated: false },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
expect(screen.queryByText("Sign in to at least one provider to get started.")).toBeNull();
// Provider rows should still be visible
expect(screen.getByText("Anthropic")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
});
// --- Mobile structure tests (DOM classes that responsive CSS targets) ---
it("has .settings-layout wrapping sidebar and content", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const layout = container.querySelector(".settings-layout");
expect(layout).toBeTruthy();
expect(layout!.querySelector(".settings-sidebar")).toBeTruthy();
expect(layout!.querySelector(".settings-content")).toBeTruthy();
});
it("has .settings-sidebar with 7 .settings-nav-item buttons for all sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
expect(navItems.length).toBe(7);
const labels = Array.from(navItems).map((el) => el.textContent);
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Authentication"]);
});
it("has .settings-content as sibling of .settings-sidebar", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const layout = container.querySelector(".settings-layout");
const children = Array.from(layout!.children);
const sidebar = children.find((el) => el.classList.contains("settings-sidebar"));
const content = children.find((el) => el.classList.contains("settings-content"));
expect(sidebar).toBeTruthy();
expect(content).toBeTruthy();
});
it("marks the active nav item with .active class", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Default active section is General
const activeItems = container.querySelectorAll(".settings-nav-item.active");
expect(activeItems.length).toBe(1);
expect(activeItems[0].textContent).toBe("General");
// Switch to Scheduling
fireEvent.click(screen.getByText("Scheduling"));
const newActive = container.querySelectorAll(".settings-nav-item.active");
expect(newActive.length).toBe(1);
expect(newActive[0].textContent).toBe("Scheduling");
});
it("auth provider rows contain .auth-provider-info and action button", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "github", name: "GitHub", authenticated: false },
],
});
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Authentication"));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const rows = container.querySelectorAll(".auth-provider-row");
expect(rows.length).toBe(2);
for (const row of rows) {
expect(row.querySelector(".auth-provider-info")).toBeTruthy();
expect(row.querySelector("button")).toBeTruthy();
}
});
});