Files
fusion/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx
gsxdsm fd4a80b43c feat(KB-061): add model filter search to model selectors
- Add search input with clear button and results count to ModelSelectorTab
- Extract filterModels utility for shared filtering logic across components
- Apply filtering to SettingsModal model section
- Add CSS styles for filter UI components
- Add comprehensive tests for ModelSelectorTab and SettingsModal filtering
2026-03-29 20:55:01 -07:00

965 lines
40 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 userEvent from "@testing-library/user-event";
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: "",
autoResolveConflicts: true,
smartConflictResolution: true,
ntfyEnabled: false,
ntfyTopic: undefined,
};
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();
expect(screen.getByText("Auto-resolve conflicts in lock files and generated files")).toBeTruthy();
expect(screen.getByText("Smart conflict resolution")).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("shows Auto-resolve conflicts 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("Auto-resolve conflicts in lock files and generated files");
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("toggling autoResolveConflicts checkbox sends false in save payload when unchecked", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Merge"));
const checkbox = screen.getByLabelText("Auto-resolve conflicts in lock files and generated files");
// 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.autoResolveConflicts).toBe(false);
});
it("autoResolveConflicts defaults to enabled (true) when setting is true", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
autoResolveConflicts: true,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Merge"));
const checkbox = screen.getByLabelText("Auto-resolve conflicts in lock files and generated files") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it("shows Smart conflict resolution 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("Smart conflict resolution");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
});
it("toggling smartConflictResolution checkbox sends false in save payload when unchecked", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Merge"));
const checkbox = screen.getByLabelText("Smart conflict resolution");
// 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.smartConflictResolution).toBe(false);
});
it("smartConflictResolution defaults to enabled (true) when setting is true", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
smartConflictResolution: true,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Merge"));
const checkbox = screen.getByLabelText("Smart conflict resolution") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it("smartConflictResolution checkbox submits true in save payload when checked", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
smartConflictResolution: false,
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Merge"));
const checkbox = screen.getByLabelText("Smart conflict resolution");
// Default is unchecked (false), click to check
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.smartConflictResolution).toBe(true);
});
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 8 .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(9);
const labels = Array.from(navItems).map((el) => el.textContent);
expect(labels).toEqual(["General", "Model", "Appearance", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "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();
}
});
// --- Notifications section tests ---
it("shows Notifications in sidebar", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getAllByText("Notifications").length).toBeGreaterThanOrEqual(1);
});
it("shows ntfy enable checkbox in Notifications section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
});
it("ntfy topic input is hidden when ntfy is disabled", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
expect(screen.queryByLabelText("ntfy Topic")).toBeNull();
});
it("ntfy topic input is visible when ntfy is enabled", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
});
it("toggling ntfyEnabled checkbox sends true in save payload", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
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.ntfyEnabled).toBe(true);
});
it("ntfy topic field saves correctly when set", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "my-topic" } });
expect(input.value).toBe("my-topic");
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.ntfyTopic).toBe("my-topic");
});
it("ntfy topic field submits undefined when empty", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "existing-topic",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const input = screen.getByLabelText("ntfy Topic") 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.ntfyTopic).toBeUndefined();
});
it("ntfy topic shows validation error for invalid input", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "invalid topic with spaces!" } });
expect(screen.getByText("Topic must be 164 alphanumeric, hyphen, or underscore characters")).toBeTruthy();
});
it("ntfyEnabled defaults to false when setting is undefined", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it("ntfyEnabled shows correct state when enabled in settings", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
});
// Model filter tests
it("renders filter input 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());
// Filter input should be present
expect(screen.getByPlaceholderText("Filter models…")).toBeTruthy();
});
it("filters default model options in Model section", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Type a filter - only claude model should match
const filterInput = screen.getByPlaceholderText("Filter models…");
await user.type(filterInput, "claude");
// Should show result count (1 model matches "claude")
expect(screen.getByText("1 model")).toBeTruthy();
// The select should be updated (Use default + 1 filtered model)
const select = screen.getByLabelText("Default Model") as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.textContent);
expect(options).toContain("Use default");
expect(options).toContain("Claude Sonnet 4.5");
expect(options).not.toContain("GPT-4o");
});
it("clear button resets filter in Model section", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Type a filter
const filterInput = screen.getByPlaceholderText("Filter models…");
await user.type(filterInput, "openai");
// Should show filtered count
expect(screen.getByText("1 model")).toBeTruthy();
// Click clear button
const clearButton = screen.getByLabelText("Clear filter");
await user.click(clearButton);
// Filter should be cleared
expect(filterInput).toHaveValue("");
expect(screen.getByText("2 models")).toBeTruthy();
// All models should be visible again
const select = screen.getByLabelText("Default Model") as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.textContent);
expect(options).toContain("GPT-4o");
expect(options).toContain("Claude Sonnet 4.5");
});
it("shows empty state in Model section when filter matches nothing", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Type a filter that matches nothing
const filterInput = screen.getByPlaceholderText("Filter models…");
await user.type(filterInput, "nonexistent");
// Should show no results message
expect(screen.getByText("No models match 'nonexistent'")).toBeTruthy();
expect(screen.getByText("0 models")).toBeTruthy();
});
});