3649 lines
154 KiB
TypeScript
3649 lines
154 KiB
TypeScript
import { useState } from "react";
|
||
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, ThemeMode, ColorTheme } from "@fusion/core";
|
||
|
||
const defaultSettings: Settings = {
|
||
maxConcurrent: 2,
|
||
maxWorktrees: 4,
|
||
pollIntervalMs: 15000,
|
||
groupOverlappingFiles: false,
|
||
autoMerge: true,
|
||
mergeStrategy: "direct",
|
||
recycleWorktrees: false,
|
||
worktreeInitCommand: "",
|
||
testCommand: "",
|
||
buildCommand: "",
|
||
autoResolveConflicts: true,
|
||
smartConflictResolution: true,
|
||
modelPresets: [],
|
||
autoSelectModelPreset: false,
|
||
defaultPresetBySize: {},
|
||
ntfyEnabled: false,
|
||
ntfyTopic: undefined,
|
||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||
taskStuckTimeoutMs: undefined,
|
||
maxStuckKills: 6,
|
||
specStalenessEnabled: false,
|
||
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
|
||
runStepsInNewSessions: false,
|
||
maxParallelSteps: 2,
|
||
showQuickChatFAB: false,
|
||
};
|
||
|
||
vi.mock("../../api", () => ({
|
||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||
fetchSettingsByScope: vi.fn(() => Promise.resolve({ global: { ...defaultSettings }, project: {} })),
|
||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||
updateGlobalSettings: 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 })),
|
||
saveApiKey: vi.fn(() => Promise.resolve({ success: true })),
|
||
clearApiKey: vi.fn(() => Promise.resolve({ success: true })),
|
||
fetchModels: vi.fn(() => Promise.resolve({
|
||
models: [
|
||
{ 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 },
|
||
],
|
||
favoriteProviders: [],
|
||
favoriteModels: [],
|
||
})),
|
||
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
||
// Global concurrency mocks
|
||
fetchGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })),
|
||
updateGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })),
|
||
// Plugin API mocks
|
||
fetchPlugins: vi.fn(() => Promise.resolve([])),
|
||
installPlugin: vi.fn(() => Promise.resolve({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", state: "started" as const, enabled: true, settings: {}, settingsSchema: {} })),
|
||
enablePlugin: vi.fn(() => Promise.resolve({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", state: "started" as const, enabled: true, settings: {}, settingsSchema: {} })),
|
||
disablePlugin: vi.fn(() => Promise.resolve({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", state: "stopped" as const, enabled: false, settings: {}, settingsSchema: {} })),
|
||
uninstallPlugin: vi.fn(() => Promise.resolve()),
|
||
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
|
||
updatePluginSettings: vi.fn(() => Promise.resolve({})),
|
||
reloadPlugin: vi.fn(() => Promise.resolve({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", state: "started" as const, enabled: true, settings: {}, settingsSchema: {} })),
|
||
fetchBackups: vi.fn(() => Promise.resolve({ backups: [], totalSize: 0 })),
|
||
createBackup: vi.fn(() => Promise.resolve({ success: true })),
|
||
exportSettings: vi.fn(() => Promise.resolve({ version: 1, exportedAt: new Date().toISOString(), global: undefined, project: {} })),
|
||
importSettings: vi.fn(() => Promise.resolve({ success: true, globalCount: 0, projectCount: 0 })),
|
||
fetchMemory: vi.fn(() => Promise.resolve({ content: "" })),
|
||
saveMemory: vi.fn(() => Promise.resolve({ success: true })),
|
||
}));
|
||
|
||
// Mock PluginManager to avoid SSE setup in tests
|
||
vi.mock("../PluginManager", () => ({
|
||
PluginManager: vi.fn(({ addToast }) => (
|
||
<div data-testid="plugin-manager">
|
||
<p>Plugin Manager Component</p>
|
||
<button onClick={() => addToast?.("Plugin action", "success")}>Mock Plugin Action</button>
|
||
</div>
|
||
)),
|
||
}));
|
||
|
||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchGlobalConcurrency, updateGlobalConcurrency } 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 when General section is selected", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Click on General (Authentication is now default, so we need to navigate)
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
|
||
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("invokes appearance callbacks when theme controls are used", async () => {
|
||
const handleThemeModeChange = vi.fn();
|
||
const handleColorThemeChange = vi.fn();
|
||
|
||
render(
|
||
<SettingsModal
|
||
onClose={onClose}
|
||
addToast={addToast}
|
||
themeMode="dark"
|
||
colorTheme="default"
|
||
onThemeModeChange={handleThemeModeChange}
|
||
onColorThemeChange={handleColorThemeChange}
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||
fireEvent.click(screen.getByRole("button", { name: "Light mode" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "Forest theme" }));
|
||
|
||
expect(handleThemeModeChange).toHaveBeenCalledWith("light");
|
||
expect(handleColorThemeChange).toHaveBeenCalledWith("forest");
|
||
});
|
||
|
||
it("reflects selected appearance values when parent updates controlled props", async () => {
|
||
function ControlledAppearanceModal() {
|
||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||
const [colorTheme, setColorTheme] = useState<ColorTheme>("default");
|
||
|
||
return (
|
||
<SettingsModal
|
||
onClose={onClose}
|
||
addToast={addToast}
|
||
themeMode={themeMode}
|
||
colorTheme={colorTheme}
|
||
onThemeModeChange={setThemeMode}
|
||
onColorThemeChange={setColorTheme}
|
||
/>
|
||
);
|
||
}
|
||
|
||
render(<ControlledAppearanceModal />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||
|
||
const lightModeButton = screen.getByRole("button", { name: "Light mode" });
|
||
const forestThemeButton = screen.getByRole("button", { name: "Forest theme" });
|
||
|
||
fireEvent.click(lightModeButton);
|
||
fireEvent.click(forestThemeButton);
|
||
|
||
await waitFor(() => {
|
||
expect(lightModeButton).toHaveAttribute("aria-pressed", "true");
|
||
expect(forestThemeButton).toHaveAttribute("aria-pressed", "true");
|
||
expect(screen.getByText("Light / Forest")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("Appearance controls invoke theme callbacks and keep UI state in sync", async () => {
|
||
const user = userEvent.setup();
|
||
const themeModeSpy = vi.fn();
|
||
const colorThemeSpy = vi.fn();
|
||
|
||
function ThemeHarness() {
|
||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||
const [colorTheme, setColorTheme] = useState<ColorTheme>("default");
|
||
|
||
return (
|
||
<SettingsModal
|
||
onClose={onClose}
|
||
addToast={addToast}
|
||
initialSection="appearance"
|
||
themeMode={themeMode}
|
||
colorTheme={colorTheme}
|
||
onThemeModeChange={(mode) => {
|
||
themeModeSpy(mode);
|
||
setThemeMode(mode);
|
||
}}
|
||
onColorThemeChange={(theme) => {
|
||
colorThemeSpy(theme);
|
||
setColorTheme(theme);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
render(<ThemeHarness />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
expect(screen.getByText(/Dark \/ Default/)).toBeTruthy();
|
||
|
||
await user.click(screen.getByLabelText("Light mode"));
|
||
await user.click(screen.getByLabelText("Ocean theme"));
|
||
|
||
expect(themeModeSpy).toHaveBeenCalledWith("light");
|
||
expect(colorThemeSpy).toHaveBeenCalledWith("ocean");
|
||
expect(screen.getByText(/Light \/ Ocean/)).toBeTruthy();
|
||
|
||
const lightButton = screen.getByLabelText("Light mode");
|
||
const oceanButton = screen.getByLabelText("Ocean theme");
|
||
expect(lightButton.getAttribute("aria-pressed")).toBe("true");
|
||
expect(oceanButton.getAttribute("aria-pressed")).toBe("true");
|
||
});
|
||
|
||
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.getByLabelText("Global Max Concurrent")).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("invokes appearance callbacks when theme controls are used", async () => {
|
||
const handleThemeModeChange = vi.fn();
|
||
const handleColorThemeChange = vi.fn();
|
||
|
||
render(
|
||
<SettingsModal
|
||
onClose={onClose}
|
||
addToast={addToast}
|
||
themeMode="dark"
|
||
colorTheme="default"
|
||
onThemeModeChange={handleThemeModeChange}
|
||
onColorThemeChange={handleColorThemeChange}
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||
fireEvent.click(screen.getByRole("button", { name: "Light mode" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "Forest theme" }));
|
||
|
||
expect(handleThemeModeChange).toHaveBeenCalledWith("light");
|
||
expect(handleColorThemeChange).toHaveBeenCalledWith("forest");
|
||
});
|
||
|
||
it("reflects selected appearance values when parent updates controlled props", async () => {
|
||
function ControlledAppearanceModal() {
|
||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||
const [colorTheme, setColorTheme] = useState<ColorTheme>("default");
|
||
|
||
return (
|
||
<SettingsModal
|
||
onClose={onClose}
|
||
addToast={addToast}
|
||
themeMode={themeMode}
|
||
colorTheme={colorTheme}
|
||
onThemeModeChange={setThemeMode}
|
||
onColorThemeChange={setColorTheme}
|
||
/>
|
||
);
|
||
}
|
||
|
||
render(<ControlledAppearanceModal />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||
|
||
const lightModeButton = screen.getByRole("button", { name: "Light mode" });
|
||
const forestThemeButton = screen.getByRole("button", { name: "Forest theme" });
|
||
|
||
fireEvent.click(lightModeButton);
|
||
fireEvent.click(forestThemeButton);
|
||
|
||
await waitFor(() => {
|
||
expect(lightModeButton).toHaveAttribute("aria-pressed", "true");
|
||
expect(forestThemeButton).toHaveAttribute("aria-pressed", "true");
|
||
expect(screen.getByText("Light / Forest")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("all settings fields are present across all sections", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// General
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
|
||
|
||
// Scheduling
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
|
||
expect(screen.getByLabelText("Global Max Concurrent")).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.getByLabelText("Auto-completion mode")).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());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
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());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
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());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "bad" } });
|
||
|
||
expect(screen.getByText("Prefix must be 1–10 uppercase letters")).toBeTruthy();
|
||
});
|
||
|
||
it("Task Prefix validation error prevents save", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
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 Show quick chat button checkbox in General section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const checkbox = screen.getByLabelText("Show quick chat button");
|
||
expect(checkbox).toBeTruthy();
|
||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||
});
|
||
|
||
it("showQuickChatFAB defaults to unchecked (false) when not set", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const checkbox = screen.getByLabelText("Show quick chat button") as HTMLInputElement;
|
||
expect(checkbox.checked).toBe(false);
|
||
});
|
||
|
||
it("showQuickChatFAB defaults to checked when setting is true", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
showQuickChatFAB: true,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const checkbox = screen.getByLabelText("Show quick chat button") as HTMLInputElement;
|
||
expect(checkbox.checked).toBe(true);
|
||
});
|
||
|
||
it("showQuickChatFAB is unchecked when setting is false", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
showQuickChatFAB: false,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const checkbox = screen.getByLabelText("Show quick chat button") as HTMLInputElement;
|
||
expect(checkbox.checked).toBe(false);
|
||
});
|
||
|
||
it("toggling showQuickChatFAB checkbox sends true in save payload when checked", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const checkbox = screen.getByLabelText("Show quick chat button");
|
||
// 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.showQuickChatFAB).toBe(true);
|
||
});
|
||
|
||
it("toggling showQuickChatFAB checkbox sends false in save payload when unchecked", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
showQuickChatFAB: true,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const checkbox = screen.getByLabelText("Show quick chat button");
|
||
// 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.showQuickChatFAB).toBe(false);
|
||
});
|
||
|
||
it("shows Auto-completion mode select in Merge section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Merge"));
|
||
const select = screen.getByLabelText("Auto-completion mode") as HTMLSelectElement;
|
||
expect(select).toBeTruthy();
|
||
expect(select.value).toBe("direct");
|
||
});
|
||
|
||
it("saves pull-request mergeStrategy when selected", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Merge"));
|
||
const select = screen.getByLabelText("Auto-completion mode") as HTMLSelectElement;
|
||
fireEvent.change(select, { target: { value: "pull-request" } });
|
||
|
||
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.mergeStrategy).toBe("pull-request");
|
||
});
|
||
|
||
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("shows Add author attribution to commits 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("Add author attribution to commits");
|
||
expect(checkbox).toBeTruthy();
|
||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||
});
|
||
|
||
it("toggling commitAuthorEnabled 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("Add author attribution to commits");
|
||
// 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.commitAuthorEnabled).toBe(false);
|
||
});
|
||
|
||
it("shows author name and email fields when commitAuthorEnabled is true", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Merge"));
|
||
// Author name and email fields should be visible when author attribution is enabled
|
||
expect(screen.getByLabelText("Author Name")).toBeTruthy();
|
||
expect(screen.getByLabelText("Author Email")).toBeTruthy();
|
||
});
|
||
|
||
it("hides author name and email fields when commitAuthorEnabled is false", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
commitAuthorEnabled: false,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Merge"));
|
||
// Author name and email fields should be hidden
|
||
expect(screen.queryByLabelText("Author Name")).toBeNull();
|
||
expect(screen.queryByLabelText("Author Email")).toBeNull();
|
||
});
|
||
|
||
it("sends custom author name and email in save payload", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Merge"));
|
||
|
||
// Change author name
|
||
const nameInput = screen.getByLabelText("Author Name");
|
||
fireEvent.change(nameInput, { target: { value: "CustomBot" } });
|
||
|
||
// Change author email
|
||
const emailInput = screen.getByLabelText("Author Email");
|
||
fireEvent.change(emailInput, { target: { value: "bot@example.com" } });
|
||
|
||
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.commitAuthorName).toBe("CustomBot");
|
||
expect(payload.commitAuthorEmail).toBe("bot@example.com");
|
||
});
|
||
|
||
it("clears author name to undefined when input is emptied", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
commitAuthorName: "SomeBot",
|
||
commitAuthorEmail: "some@example.com",
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Merge"));
|
||
|
||
// Clear author name
|
||
const nameInput = screen.getByLabelText("Author Name");
|
||
fireEvent.change(nameInput, { 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.commitAuthorName).toBeUndefined();
|
||
});
|
||
|
||
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("loads and saves the central global concurrency limit", async () => {
|
||
(fetchGlobalConcurrency as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
globalMaxConcurrent: 8,
|
||
currentlyActive: 3,
|
||
queuedCount: 0,
|
||
projectsActive: {},
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} initialSection="scheduling" />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
await waitFor(() => expect(fetchGlobalConcurrency).toHaveBeenCalled());
|
||
|
||
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
|
||
expect(input.value).toBe("8");
|
||
|
||
fireEvent.change(input, { target: { value: "10" } });
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateGlobalConcurrency).toHaveBeenCalledWith({ globalMaxConcurrent: 10 }));
|
||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(projectPayload.globalMaxConcurrent).toBeUndefined();
|
||
});
|
||
|
||
it("saving in General section updates project settings with task prefix", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Click on General to navigate to General section
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
|
||
// General section is project-scoped — change a project setting
|
||
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "TEST" } });
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Verify taskPrefix is saved as project setting
|
||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(projectPayload.taskPrefix).toBe("TEST");
|
||
});
|
||
|
||
it("saving in Models section updates global settings with default model", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Click on "Models" (global models section)
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Select a model
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
await user.click(trigger);
|
||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
// Verify default model settings are saved as global settings
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(globalPayload.defaultProvider).toBe("anthropic");
|
||
expect(globalPayload.defaultModelId).toBe("claude-sonnet-4-5");
|
||
});
|
||
|
||
it("saving in Models section updates global fallback model", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Click on "Models" (global models section)
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
const trigger = screen.getByLabelText("Fallback Model");
|
||
await user.click(trigger);
|
||
await user.click(screen.getByText("GPT-4o"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(globalPayload.fallbackProvider).toBe("openai");
|
||
expect(globalPayload.fallbackModelId).toBe("gpt-4o");
|
||
});
|
||
|
||
it("stores favoriteModels from fetchModels response", async () => {
|
||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
models: [
|
||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
|
||
],
|
||
favoriteProviders: ["anthropic"],
|
||
favoriteModels: ["anthropic/claude-sonnet-4-5"],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Click on "Models" (global models section)
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// No error thrown means favoriteModels was accepted
|
||
expect(screen.getByLabelText("Default Model")).toBeTruthy();
|
||
});
|
||
|
||
it("saving in Project Models section updates project settings with planning and validator models", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Click on "Project Models" (project-scoped models section)
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Select planning model
|
||
const planningTrigger = screen.getByLabelText("Planning Model");
|
||
await user.click(planningTrigger);
|
||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||
|
||
// Select validator model
|
||
const validatorTrigger = screen.getByLabelText("Validator Model");
|
||
await user.click(validatorTrigger);
|
||
await user.click(screen.getByText("GPT-4o"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
// Verify planning and validator settings are saved as project settings
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(projectPayload.planningProvider).toBe("anthropic");
|
||
expect(projectPayload.planningModelId).toBe("claude-sonnet-4-5");
|
||
expect(projectPayload.validatorProvider).toBe("openai");
|
||
expect(projectPayload.validatorModelId).toBe("gpt-4o");
|
||
});
|
||
|
||
it("saving in Project Models section updates planning and validator fallback models", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Click on "Project Models" (project-scoped models section)
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
const planningFallbackTrigger = screen.getByLabelText("Planning Fallback Model");
|
||
await user.click(planningFallbackTrigger);
|
||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||
|
||
const validatorFallbackTrigger = screen.getByLabelText("Validator Fallback Model");
|
||
await user.click(validatorFallbackTrigger);
|
||
await user.click(screen.getByText("GPT-4o"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(projectPayload.planningFallbackProvider).toBe("anthropic");
|
||
expect(projectPayload.planningFallbackModelId).toBe("claude-sonnet-4-5");
|
||
expect(projectPayload.validatorFallbackProvider).toBe("openai");
|
||
expect(projectPayload.validatorFallbackModelId).toBe("gpt-4o");
|
||
});
|
||
|
||
it("shows Models and Project Models in sidebar", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
expect(screen.getAllByText("Models").length).toBeGreaterThanOrEqual(1);
|
||
expect(screen.getAllByText("Project Models").length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it("supports creating and saving a model preset", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Model presets are in Project Models section
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await user.click(screen.getByText("Add Preset"));
|
||
|
||
await user.type(screen.getByLabelText("Name"), "Budget");
|
||
|
||
await user.click(screen.getByText("Save preset"));
|
||
await user.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.modelPresets).toEqual([
|
||
expect.objectContaining({ id: "budget", name: "Budget" }),
|
||
]);
|
||
});
|
||
|
||
it("supports auto-select preset mappings by size", async () => {
|
||
const user = userEvent.setup();
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
modelPresets: [
|
||
{ id: "budget", name: "Budget" },
|
||
{ id: "normal", name: "Normal" },
|
||
{ id: "complex", name: "Complex" },
|
||
],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Auto-select presets are in Project Models section
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await user.click(screen.getByLabelText("Auto-select preset based on task size"));
|
||
fireEvent.change(screen.getByLabelText("Small tasks (S):"), { target: { value: "budget" } });
|
||
fireEvent.change(screen.getByLabelText("Medium tasks (M):"), { target: { value: "normal" } });
|
||
fireEvent.change(screen.getByLabelText("Large tasks (L):"), { target: { value: "complex" } });
|
||
|
||
await user.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.autoSelectModelPreset).toBe(true);
|
||
expect(payload.defaultPresetBySize).toEqual({ S: "budget", M: "normal", L: "complex" });
|
||
});
|
||
|
||
it("shows model selector with available models grouped by provider", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Models section has default model dropdown
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Dropdown trigger should be present
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
expect(trigger).toBeTruthy();
|
||
expect(trigger.tagName).toBe("BUTTON");
|
||
|
||
// Open dropdown to see models
|
||
await user.click(trigger);
|
||
|
||
// Models should be visible in dropdown
|
||
expect(screen.getByText("Claude Sonnet 4.5")).toBeTruthy();
|
||
expect(screen.getByText("GPT-4o")).toBeTruthy();
|
||
|
||
// "Use default" appears twice (trigger text + dropdown option) - use getAllByText
|
||
const useDefaultElements = screen.getAllByText("Use default");
|
||
expect(useDefaultElements.length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it("selecting a model updates form with provider and model ID", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Models section has default model dropdown
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open dropdown and select a model
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
await user.click(trigger);
|
||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
// defaultProvider and defaultModelId are global settings, so they go through updateGlobalSettings
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings 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 (sends null for explicit clear)", async () => {
|
||
const user = userEvent.setup();
|
||
(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());
|
||
|
||
// Models section has default model dropdown
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open dropdown and select "Use default"
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
await user.click(trigger);
|
||
|
||
// Find and click the "Use default" option in the dropdown
|
||
const defaultOptions = screen.getAllByText("Use default");
|
||
const dropdownDefault = defaultOptions.find((el) =>
|
||
el.classList.contains("model-combobox-option-text--default")
|
||
);
|
||
if (dropdownDefault) {
|
||
await user.click(dropdownDefault);
|
||
}
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
// defaultProvider and defaultModelId are global settings
|
||
// Clearing sends null (null-as-delete semantics)
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.defaultProvider).toBeNull();
|
||
expect(payload.defaultModelId).toBeNull();
|
||
});
|
||
|
||
it("shows empty state when no models available", async () => {
|
||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Models section shows empty state when no models available
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText("No models available. Configure authentication first.").length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
// --- Planning & Validation model tests ---
|
||
|
||
it("shows Project Models section with planning and validator model dropdowns", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Project Models section has planning and validator model dropdowns
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Both dropdowns should be present
|
||
expect(screen.getByLabelText("Planning Model")).toBeTruthy();
|
||
expect(screen.getByLabelText("Validator Model")).toBeTruthy();
|
||
});
|
||
|
||
it("selecting a planning model updates form with provider and model ID", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Project Models section has planning model dropdown
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open planning model dropdown and select a model
|
||
const trigger = screen.getByLabelText("Planning Model");
|
||
await user.click(trigger);
|
||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
// planningProvider and planningModelId are project settings, so they go through updateSettings
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.planningProvider).toBe("anthropic");
|
||
expect(payload.planningModelId).toBe("claude-sonnet-4-5");
|
||
});
|
||
|
||
it("selecting a validator model updates form with provider and model ID", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Project Models section has validator model dropdown
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open validator model dropdown and select a model
|
||
const trigger = screen.getByLabelText("Validator Model");
|
||
await user.click(trigger);
|
||
await user.click(screen.getByText("GPT-4o"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
// validatorProvider and validatorModelId are project settings, so they go through updateSettings
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.validatorProvider).toBe("openai");
|
||
expect(payload.validatorModelId).toBe("gpt-4o");
|
||
});
|
||
|
||
describe("scope-safe save payloads", () => {
|
||
it("global lane edits go to updateGlobalSettings and NOT to project settings", async () => {
|
||
const user = userEvent.setup();
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Models (global section) and change default model
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
const defaultTrigger = screen.getByLabelText("Default Model");
|
||
await user.click(defaultTrigger);
|
||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Global settings should have the new default model
|
||
const globalPayload = vi.mocked(updateGlobalSettings).mock.calls[0][0];
|
||
expect(globalPayload.defaultProvider).toBe("anthropic");
|
||
expect(globalPayload.defaultModelId).toBe("claude-sonnet-4-5");
|
||
|
||
// Project settings should NOT contain global lane keys
|
||
const projectPayload = vi.mocked(updateSettings).mock.calls[0][0];
|
||
expect(projectPayload).not.toHaveProperty("defaultProvider");
|
||
expect(projectPayload).not.toHaveProperty("defaultModelId");
|
||
});
|
||
|
||
it("project lane overrides go to updateSettings and NOT to global settings", async () => {
|
||
const user = userEvent.setup();
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Project Models section and change planning model
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
const planningTrigger = screen.getByLabelText("Planning Model");
|
||
await user.click(planningTrigger);
|
||
await user.click(screen.getByText("GPT-4o"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Project settings should have the planning override
|
||
const projectPayload = vi.mocked(updateSettings).mock.calls[0][0];
|
||
expect(projectPayload.planningProvider).toBe("openai");
|
||
expect(projectPayload.planningModelId).toBe("gpt-4o");
|
||
|
||
// Global settings should NOT contain project override keys
|
||
const globalPayload = vi.mocked(updateGlobalSettings).mock.calls[0][0];
|
||
expect(globalPayload).not.toHaveProperty("planningProvider");
|
||
expect(globalPayload).not.toHaveProperty("planningModelId");
|
||
});
|
||
|
||
it("resetting a project lane sends null-as-delete payload", async () => {
|
||
const user = userEvent.setup();
|
||
// Set up with existing planning override in the merged settings
|
||
const settingsWithOverride = {
|
||
...defaultSettings,
|
||
planningProvider: "anthropic",
|
||
planningModelId: "claude-sonnet-4-5",
|
||
};
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settingsWithOverride);
|
||
// And in scoped settings
|
||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
global: { ...defaultSettings },
|
||
project: {
|
||
planningProvider: "anthropic",
|
||
planningModelId: "claude-sonnet-4-5",
|
||
},
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Project Models section
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Verify the planning lane shows as overridden
|
||
const planningBadge = screen.getByText("Override (Project)");
|
||
expect(planningBadge).toBeTruthy();
|
||
|
||
// Click reset button for planning lane
|
||
const resetButtons = screen.getAllByText("Reset");
|
||
await user.click(resetButtons[0]);
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Project settings should have null for the reset lane
|
||
const projectPayload = vi.mocked(updateSettings).mock.calls[0][0];
|
||
expect(projectPayload.planningProvider).toBeNull();
|
||
expect(projectPayload.planningModelId).toBeNull();
|
||
});
|
||
|
||
it("inherited lanes are not written to project payload", async () => {
|
||
const user = userEvent.setup();
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Project Models section
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Verify lanes show as inherited (don't change anything)
|
||
const inheritedBadges = screen.getAllByText("Inherited (Global)");
|
||
expect(inheritedBadges.length).toBeGreaterThanOrEqual(1);
|
||
|
||
// Just save without changing anything
|
||
fireEvent.click(screen.getByText("Save"));
|
||
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Project settings should NOT contain model lane keys (they're inherited)
|
||
const projectPayload = vi.mocked(updateSettings).mock.calls[0][0];
|
||
expect(projectPayload).not.toHaveProperty("planningProvider");
|
||
expect(projectPayload).not.toHaveProperty("planningModelId");
|
||
expect(projectPayload).not.toHaveProperty("validatorProvider");
|
||
expect(projectPayload).not.toHaveProperty("validatorModelId");
|
||
});
|
||
});
|
||
|
||
it("shows empty state in Models section when no models available", async () => {
|
||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getAllByText("No models available. Configure authentication first.").length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
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("Authentication nav item has globe icon in sidebar", async () => {
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Find the Authentication nav item in the sidebar
|
||
const authNavItem = container.querySelector(".settings-nav-item");
|
||
expect(authNavItem).toBeTruthy();
|
||
expect(authNavItem?.textContent?.trim()).toBe("Authentication");
|
||
|
||
// Check that it has a globe icon (Globe icon component renders as SVG with specific aria-label)
|
||
const globeIcon = authNavItem?.querySelector('[aria-label="Global setting"]');
|
||
expect(globeIcon).toBeTruthy();
|
||
});
|
||
|
||
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.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||
expect(screen.getByText("✗ Not connected")).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.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
expect(screen.getByText("✓ Active")).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.getAllByText("Authentication")[0]);
|
||
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.getAllByText("Authentication")[0]);
|
||
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.getAllByText("Authentication")[0]);
|
||
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("renders authenticated providers before unauthenticated when API returns mixed order", async () => {
|
||
// API returns GitHub first (authenticated) and Anthropic second (not authenticated)
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "github", name: "GitHub", authenticated: true },
|
||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||
],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// GitHub (authenticated) should appear first
|
||
const container = document.body;
|
||
const cards = container.querySelectorAll(".auth-provider-card");
|
||
expect(cards.length).toBe(2);
|
||
|
||
// First card should be GitHub (authenticated) - check by looking for "GitHub" text in first card
|
||
const firstCardText = cards[0].textContent;
|
||
expect(firstCardText).toContain("GitHub");
|
||
expect(firstCardText).toContain("✓ Active");
|
||
|
||
// Second card should be Anthropic (unauthenticated)
|
||
const secondCardText = cards[1].textContent;
|
||
expect(secondCardText).toContain("Anthropic");
|
||
expect(secondCardText).toContain("✗ Not connected");
|
||
});
|
||
|
||
it("sorts providers alphabetically within each auth-state bucket", async () => {
|
||
// API returns in reverse alphabetical order
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "zod", name: "Zod", authenticated: false },
|
||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||
{ id: "azure", name: "Azure", authenticated: false },
|
||
],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const cards = document.body.querySelectorAll(".auth-provider-card");
|
||
expect(cards.length).toBe(3);
|
||
|
||
// Should be sorted alphabetically: Anthropic, Azure, Zod
|
||
const names = Array.from(cards).map(card => {
|
||
const strong = card.querySelector("strong");
|
||
return strong?.textContent;
|
||
});
|
||
expect(names).toEqual(["Anthropic", "Azure", "Zod"]);
|
||
});
|
||
|
||
it("renders Authenticated and Available group labels in auth section", 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.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// Should show "Authenticated" group label (authenticated providers)
|
||
expect(screen.getAllByText("Authenticated").length).toBeGreaterThanOrEqual(1);
|
||
// Should show "Available" group label (unauthenticated providers)
|
||
expect(screen.getAllByText("Available").length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it("auth provider rows use auth-provider-card class", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const providerRow = screen.getByText("Anthropic").closest(".auth-provider-card");
|
||
expect(providerRow).toBeTruthy();
|
||
});
|
||
|
||
it("model section renders CustomModelDropdown button", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// CustomModelDropdown renders as a button trigger, not a select element
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
expect(trigger.tagName).toBe("BUTTON");
|
||
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
|
||
|
||
// Open dropdown to verify it works
|
||
await user.click(trigger);
|
||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||
expect(screen.getByPlaceholderText("Filter models…")).toBeTruthy();
|
||
});
|
||
|
||
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(1);
|
||
});
|
||
|
||
it("shows Thinking Effort dropdown with correct options in Models section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Models"));
|
||
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("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
const select = screen.getByLabelText("Thinking Effort") as HTMLSelectElement;
|
||
fireEvent.change(select, { target: { value: "high" } });
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
// defaultThinkingLevel is a global setting
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings 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("Models"));
|
||
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.getAllByText("Authentication")[0]);
|
||
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 Authentication section when no initialSection is passed", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// Authentication content should be visible (it's the default section now)
|
||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||
// General content should NOT be visible
|
||
expect(screen.queryByLabelText("Task Prefix")).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.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
expect(screen.getByText("Sign in to at least one provider to get started with AI models.")).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.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
expect(screen.queryByText("Sign in to at least one provider to get started with AI models.")).toBeNull();
|
||
// Provider rows should still be visible
|
||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||
expect(screen.getByText("GitHub")).toBeTruthy();
|
||
});
|
||
|
||
// --- API key provider tests ---
|
||
|
||
it("renders password input and Save button for api_key providers", async () => {
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const input = screen.getByPlaceholderText("Enter API key") as HTMLInputElement;
|
||
expect(input).toBeTruthy();
|
||
expect(input.type).toBe("password");
|
||
|
||
// The Save button should be inside the auth-apikey-section, not the global save
|
||
const apiKeySection = container.querySelector(".auth-apikey-section");
|
||
expect(apiKeySection).toBeTruthy();
|
||
expect(apiKeySection!.querySelector("button")!.textContent?.trim()).toBe("Save");
|
||
|
||
// Should NOT show Login button for api_key providers
|
||
expect(screen.queryByText("Login")).toBeNull();
|
||
});
|
||
|
||
it("renders Clear button for authenticated api_key providers", async () => {
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// The Clear button should be inside the auth-apikey-section
|
||
const apiKeySection = container.querySelector(".auth-apikey-section");
|
||
expect(apiKeySection).toBeTruthy();
|
||
expect(apiKeySection!.querySelector("button")!.textContent?.trim()).toBe("Clear");
|
||
|
||
// Should NOT show Logout button for api_key providers
|
||
expect(screen.queryByText("Logout")).toBeNull();
|
||
});
|
||
|
||
it("saves API key when Save is clicked", async () => {
|
||
|
||
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||
],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const input = screen.getByPlaceholderText("Enter API key");
|
||
fireEvent.change(input, { target: { value: "sk-test-key-123" } });
|
||
|
||
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||
fireEvent.click(apiKeySection.querySelector("button")!);
|
||
|
||
await waitFor(() => expect(saveApiKey).toHaveBeenCalledWith("openrouter", "sk-test-key-123"));
|
||
expect(addToast).toHaveBeenCalledWith("API key saved", "success");
|
||
});
|
||
|
||
it("shows error when saving empty API key", async () => {
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||
fireEvent.click(apiKeySection.querySelector("button")!);
|
||
|
||
expect(screen.getByText("API key is required")).toBeTruthy();
|
||
});
|
||
|
||
it("clears API key when Clear is clicked", async () => {
|
||
|
||
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||
],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||
fireEvent.click(apiKeySection.querySelector("button")!);
|
||
|
||
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
|
||
expect(addToast).toHaveBeenCalledWith("API key cleared", "success");
|
||
});
|
||
|
||
it("handles API key save error gracefully", async () => {
|
||
|
||
(saveApiKey as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
|
||
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const input = screen.getByPlaceholderText("Enter API key");
|
||
fireEvent.change(input, { target: { value: "sk-key" } });
|
||
|
||
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||
fireEvent.click(apiKeySection.querySelector("button")!);
|
||
|
||
await waitFor(() => expect(screen.getByText("Network error")).toBeTruthy());
|
||
});
|
||
|
||
it("shows input field after clearing API key (badge updates to not authenticated)", async () => {
|
||
|
||
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>)
|
||
.mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||
],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// Initially should show "Clear" button (authenticated)
|
||
expect(container.querySelector(".auth-apikey-section button")?.textContent).toContain("Clear");
|
||
|
||
// Click Clear
|
||
fireEvent.click(container.querySelector(".auth-apikey-section button")!);
|
||
|
||
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
|
||
|
||
// After clearing, fetchAuthStatus should be called again (loadAuthStatus)
|
||
// and the UI should show an input field (not authenticated state)
|
||
await waitFor(() => {
|
||
expect(container.querySelector(".auth-apikey-section input")).toBeTruthy();
|
||
});
|
||
expect(addToast).toHaveBeenCalledWith("API key cleared", "success");
|
||
});
|
||
|
||
it("handles API key clear error gracefully", async () => {
|
||
|
||
|
||
(clearApiKey as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Clear failed"));
|
||
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const apiKeySection = container.querySelector(".auth-apikey-section")!;
|
||
fireEvent.click(apiKeySection.querySelector("button")!);
|
||
|
||
await waitFor(() => expect(clearApiKey).toHaveBeenCalledWith("openrouter"));
|
||
expect(addToast).toHaveBeenCalledWith("Clear failed", "error");
|
||
});
|
||
|
||
it("shows Login for oauth providers and password input for api_key providers side by side", async () => {
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" as const },
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// OAuth provider shows Login
|
||
expect(screen.getByText("Login")).toBeTruthy();
|
||
// API key provider shows password input
|
||
expect(screen.getByPlaceholderText("Enter API key")).toBeTruthy();
|
||
});
|
||
|
||
it("does not prefill stored key values in the input", async () => {
|
||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
providers: [
|
||
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" as const },
|
||
],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const input = screen.getByPlaceholderText("Enter API key") as HTMLInputElement;
|
||
expect(input.value).toBe("");
|
||
});
|
||
|
||
// --- 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 15 .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");
|
||
// 15 nav items (group headers are not nav items)
|
||
expect(navItems.length).toBe(15);
|
||
|
||
// Labels include scope icons (Globe for global, Folder for project)
|
||
const labels = Array.from(navItems).map((el) => el.textContent?.trim());
|
||
expect(labels).toEqual([
|
||
"Authentication",
|
||
"Appearance",
|
||
"Notifications",
|
||
"Models",
|
||
"Project Models",
|
||
"General",
|
||
"Scheduling",
|
||
"Worktrees",
|
||
"Commands",
|
||
"Merge",
|
||
"Memory",
|
||
"Experimental Features",
|
||
"Prompts",
|
||
"Backups",
|
||
"Plugins",
|
||
]);
|
||
});
|
||
|
||
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 Authentication (first in sidebar order)
|
||
const activeItems = container.querySelectorAll(".settings-nav-item.active");
|
||
expect(activeItems.length).toBe(1);
|
||
expect(activeItems[0].textContent?.trim()).toBe("Authentication");
|
||
|
||
// Switch to General
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const newActive = container.querySelectorAll(".settings-nav-item.active");
|
||
expect(newActive.length).toBe(1);
|
||
expect(newActive[0].textContent?.trim()).toBe("General");
|
||
});
|
||
|
||
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.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
const rows = container.querySelectorAll(".auth-provider-card");
|
||
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"));
|
||
// ntfyEnabled is a global setting, so it goes through updateGlobalSettings
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.ntfyEnabled).toBe(true);
|
||
});
|
||
|
||
it("openrouterModelSync checkbox defaults to enabled and sends false when toggled off", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// OpenRouter model sync is in Models section (global settings)
|
||
fireEvent.click(screen.getByText("Models"));
|
||
const checkbox = screen.getByLabelText("Sync OpenRouter model list at dashboard startup");
|
||
expect(checkbox).toBeTruthy();
|
||
expect((checkbox as HTMLInputElement).checked).toBe(true);
|
||
|
||
fireEvent.click(checkbox);
|
||
expect((checkbox as HTMLInputElement).checked).toBe(false);
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.openrouterModelSync).toBe(false);
|
||
});
|
||
|
||
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"));
|
||
// ntfyTopic is a global setting
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.ntfyTopic).toBe("my-topic");
|
||
});
|
||
|
||
it("ntfy topic field submits null when cleared (null-as-delete semantics)", 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"));
|
||
// ntfyTopic is a global setting - clearing it sends null (null-as-delete)
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.ntfyTopic).toBeNull(); // null means "explicitly clear this field"
|
||
});
|
||
|
||
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 1–64 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();
|
||
});
|
||
|
||
it("re-opening modal shows previously saved notification settings", async () => {
|
||
// First render with ntfy enabled
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "saved-topic",
|
||
});
|
||
|
||
const { unmount } = 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);
|
||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||
expect(input.value).toBe("saved-topic");
|
||
|
||
unmount();
|
||
vi.clearAllMocks();
|
||
|
||
// Re-open modal - should still show saved settings
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "saved-topic",
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
const newCheckbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
|
||
expect(newCheckbox.checked).toBe(true);
|
||
const newInput = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||
expect(newInput.value).toBe("saved-topic");
|
||
});
|
||
|
||
it("disabling ntfy persists correctly when saving", async () => {
|
||
// Start with ntfy enabled
|
||
(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);
|
||
|
||
// Disable ntfy
|
||
fireEvent.click(checkbox);
|
||
expect(checkbox.checked).toBe(false);
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
// ntfyEnabled is a global setting
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.ntfyEnabled).toBe(false);
|
||
});
|
||
|
||
it("shows ntfyEvents checkboxes when ntfy is enabled", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "my-topic",
|
||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
await waitFor(() => expect(screen.getByLabelText("Task completed (in-review)")).toBeTruthy());
|
||
expect(screen.getByLabelText("Task merged")).toBeTruthy();
|
||
expect(screen.getByLabelText("Task failed")).toBeTruthy();
|
||
expect(screen.getByLabelText("Plan needs approval")).toBeTruthy();
|
||
expect(screen.getByLabelText("User review needed")).toBeTruthy();
|
||
});
|
||
|
||
it("shows awaiting-approval checkbox when ntfy is enabled", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "my-topic",
|
||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
expect(screen.getByLabelText("Plan needs approval")).toBeTruthy();
|
||
});
|
||
|
||
it("hides ntfyEvents checkboxes when ntfy is disabled", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
expect(screen.queryByLabelText("Task completed (in-review)")).toBeNull();
|
||
expect(screen.queryByLabelText("Task merged")).toBeNull();
|
||
expect(screen.queryByLabelText("Task failed")).toBeNull();
|
||
expect(screen.queryByLabelText("Plan needs approval")).toBeNull();
|
||
expect(screen.queryByLabelText("User review needed")).toBeNull();
|
||
});
|
||
|
||
it("ntfyEvents checkboxes are all checked by default", 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"));
|
||
expect((screen.getByLabelText("Task completed (in-review)") as HTMLInputElement).checked).toBe(true);
|
||
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(true);
|
||
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(true);
|
||
expect((screen.getByLabelText("Plan needs approval") as HTMLInputElement).checked).toBe(true);
|
||
expect((screen.getByLabelText("User review needed") as HTMLInputElement).checked).toBe(true);
|
||
});
|
||
|
||
it("saves ntfyEvents correctly when checkboxes are toggled", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "my-topic",
|
||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
|
||
// Uncheck "Task merged"
|
||
const mergedCheckbox = screen.getByLabelText("Task merged");
|
||
fireEvent.click(mergedCheckbox);
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.ntfyEvents).toEqual(["in-review", "failed", "awaiting-approval", "awaiting-user-review"]);
|
||
});
|
||
|
||
it("sets ntfyEvents to null when all checkboxes are unchecked (null-as-delete)", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "my-topic",
|
||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
|
||
// Uncheck all five
|
||
fireEvent.click(screen.getByLabelText("Task completed (in-review)"));
|
||
fireEvent.click(screen.getByLabelText("Task merged"));
|
||
fireEvent.click(screen.getByLabelText("Task failed"));
|
||
fireEvent.click(screen.getByLabelText("Plan needs approval"));
|
||
fireEvent.click(screen.getByLabelText("User review needed"));
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(updateGlobalSettings).toHaveBeenCalledTimes(1));
|
||
|
||
const payload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.ntfyEvents).toBeNull(); // null means "explicitly clear this field"
|
||
});
|
||
|
||
it("restores ntfyEvents from saved settings", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
ntfyEnabled: true,
|
||
ntfyTopic: "my-topic",
|
||
ntfyEvents: ["in-review"],
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
expect((screen.getByLabelText("Task completed (in-review)") as HTMLInputElement).checked).toBe(true);
|
||
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(false);
|
||
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(false);
|
||
expect((screen.getByLabelText("Plan needs approval") as HTMLInputElement).checked).toBe(false);
|
||
});
|
||
|
||
// Model filter tests with CustomModelDropdown
|
||
it("renders filter input in Models section dropdown", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open dropdown to access filter input
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
await user.click(trigger);
|
||
|
||
// Filter input should be present in dropdown
|
||
expect(screen.getByPlaceholderText("Filter models…")).toBeTruthy();
|
||
});
|
||
|
||
it("filters default model options in Models section", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open dropdown
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
await user.click(trigger);
|
||
|
||
// 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();
|
||
|
||
// Only Claude should be visible, GPT-4o should be filtered out
|
||
expect(screen.getByText("Claude Sonnet 4.5")).toBeTruthy();
|
||
expect(screen.queryByText("GPT-4o")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("clear button resets filter in Models section", async () => {
|
||
const user = userEvent.setup();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open dropdown
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
await user.click(trigger);
|
||
|
||
// 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
|
||
expect(screen.getByText("GPT-4o")).toBeTruthy();
|
||
expect(screen.getByText("Claude Sonnet 4.5")).toBeTruthy();
|
||
});
|
||
|
||
it("shows empty state in Models 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("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open dropdown
|
||
const trigger = screen.getByLabelText("Default Model");
|
||
await user.click(trigger);
|
||
|
||
// 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/)).toBeTruthy();
|
||
expect(screen.getByText("0 models")).toBeTruthy();
|
||
});
|
||
|
||
// --- Test notification button tests ---
|
||
|
||
it("Test notification button is disabled when ntfy is disabled", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
|
||
// When ntfy is disabled, the topic input (and test button) should not be visible
|
||
expect(screen.queryByLabelText("ntfy Topic")).toBeNull();
|
||
expect(screen.queryByRole("button", { name: /Test notification/i })).toBeNull();
|
||
});
|
||
|
||
it("Test notification button is disabled when topic is invalid", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
|
||
// Enable ntfy
|
||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||
fireEvent.click(checkbox);
|
||
|
||
// Enter invalid topic
|
||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "invalid topic with spaces!" } });
|
||
|
||
// Test button should be disabled
|
||
const testButton = screen.getByRole("button", { name: "Test notification" });
|
||
expect(testButton).toBeDisabled();
|
||
});
|
||
|
||
it("Test notification button is enabled when ntfy is enabled with valid topic", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
|
||
// Enable ntfy
|
||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||
fireEvent.click(checkbox);
|
||
|
||
// Enter valid topic
|
||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "my-valid-topic" } });
|
||
|
||
// Test button should be enabled
|
||
const testButton = screen.getByRole("button", { name: "Test notification" });
|
||
expect(testButton).toBeEnabled();
|
||
});
|
||
|
||
it("Clicking test button calls testNtfyNotification API", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
|
||
// Enable ntfy
|
||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||
fireEvent.click(checkbox);
|
||
|
||
// Enter valid topic
|
||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "my-valid-topic" } });
|
||
|
||
// Click test button
|
||
const testButton = screen.getByRole("button", { name: "Test notification" });
|
||
fireEvent.click(testButton);
|
||
|
||
await waitFor(() => expect(testNtfyNotification).toHaveBeenCalledTimes(1));
|
||
expect(testNtfyNotification).toHaveBeenCalledWith({ ntfyEnabled: true, ntfyTopic: "my-valid-topic" }, undefined);
|
||
});
|
||
|
||
it("Success toast is shown when test notification succeeds", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
|
||
// Enable ntfy
|
||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||
fireEvent.click(checkbox);
|
||
|
||
// Enter valid topic
|
||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "my-valid-topic" } });
|
||
|
||
// Click test button
|
||
const testButton = screen.getByRole("button", { name: "Test notification" });
|
||
fireEvent.click(testButton);
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Test notification sent — check your ntfy app!", "success"));
|
||
});
|
||
|
||
it("Error toast is shown when test notification fails", async () => {
|
||
// Mock the API to return an error
|
||
(testNtfyNotification as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Notifications"));
|
||
|
||
// Enable ntfy
|
||
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
|
||
fireEvent.click(checkbox);
|
||
|
||
// Enter valid topic
|
||
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "my-valid-topic" } });
|
||
|
||
// Click test button
|
||
const testButton = screen.getByRole("button", { name: "Test notification" });
|
||
fireEvent.click(testButton);
|
||
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Network error", "error"));
|
||
});
|
||
|
||
// --- Stuck Task Timeout field tests ---
|
||
|
||
it("shows Stuck Task Timeout field in Scheduling section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const input = screen.getByLabelText("Stuck Task Timeout (minutes)");
|
||
expect(input).toBeTruthy();
|
||
expect(input.getAttribute("type")).toBe("number");
|
||
expect(input.getAttribute("min")).toBe("1");
|
||
expect(input.getAttribute("step")).toBe("1");
|
||
});
|
||
|
||
it("Stuck Task Timeout field saves correctly when set to a value", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const input = screen.getByLabelText("Stuck Task Timeout (minutes)") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "10" } });
|
||
expect(input.value).toBe("10");
|
||
|
||
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.taskStuckTimeoutMs).toBe(600000);
|
||
});
|
||
|
||
it("Stuck Task Timeout field submits undefined when set to empty (disabled)", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
taskStuckTimeoutMs: 600000,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const input = screen.getByLabelText("Stuck Task Timeout (minutes)") 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.taskStuckTimeoutMs).toBeUndefined();
|
||
});
|
||
|
||
it("Stuck Task Timeout field shows helper text", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
expect(screen.getByText(/Timeout in minutes for detecting stuck tasks/)).toBeTruthy();
|
||
});
|
||
|
||
it("Stuck Task Timeout field displays correct minute value from milliseconds setting", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
taskStuckTimeoutMs: 600000,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const input = screen.getByLabelText("Stuck Task Timeout (minutes)") as HTMLInputElement;
|
||
expect(input.value).toBe("10");
|
||
});
|
||
|
||
it("Max Stuck Retries field saves correctly when set to a value", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const input = screen.getByLabelText("Max Stuck Retries") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "8" } });
|
||
expect(input.value).toBe("8");
|
||
|
||
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.maxStuckKills).toBe(8);
|
||
});
|
||
|
||
it("Max Stuck Retries field submits undefined when cleared", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
maxStuckKills: 6,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const input = screen.getByLabelText("Max Stuck Retries") 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.maxStuckKills).toBeUndefined();
|
||
});
|
||
|
||
// --- Specification Staleness field tests ---
|
||
|
||
it("shows Specification Staleness fields in Scheduling section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement");
|
||
expect(checkbox).toBeTruthy();
|
||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||
|
||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)");
|
||
expect(thresholdInput).toBeTruthy();
|
||
expect(thresholdInput.getAttribute("type")).toBe("number");
|
||
expect(thresholdInput.getAttribute("min")).toBe("0");
|
||
});
|
||
|
||
it("Specification Staleness threshold input is disabled when toggle is off", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||
expect(thresholdInput).toBeDisabled();
|
||
});
|
||
|
||
it("Specification Staleness threshold input is enabled when toggle is on", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||
fireEvent.click(checkbox);
|
||
expect(checkbox.checked).toBe(true);
|
||
|
||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||
expect(thresholdInput).not.toBeDisabled();
|
||
});
|
||
|
||
it("Specification Staleness threshold displays rounded hours from milliseconds", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
specStalenessEnabled: true,
|
||
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||
expect(thresholdInput.value).toBe("6");
|
||
});
|
||
|
||
it("Specification Staleness threshold converts hours to milliseconds on save", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||
fireEvent.click(checkbox);
|
||
expect(checkbox.checked).toBe(true);
|
||
|
||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||
fireEvent.change(thresholdInput, { target: { value: "12" } });
|
||
expect(thresholdInput.value).toBe("12");
|
||
|
||
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.specStalenessEnabled).toBe(true);
|
||
expect(payload.specStalenessMaxAgeMs).toBe(12 * 60 * 60 * 1000);
|
||
});
|
||
|
||
it("Specification Staleness threshold of 0 hours persists 0 milliseconds", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||
fireEvent.click(checkbox);
|
||
expect(checkbox.checked).toBe(true);
|
||
|
||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||
fireEvent.change(thresholdInput, { target: { value: "0" } });
|
||
expect(thresholdInput.value).toBe("0");
|
||
|
||
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.specStalenessEnabled).toBe(true);
|
||
expect(payload.specStalenessMaxAgeMs).toBe(0);
|
||
});
|
||
|
||
it("Specification Staleness enabled with empty threshold submits without invalid numeric payload", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||
fireEvent.click(checkbox);
|
||
expect(checkbox.checked).toBe(true);
|
||
|
||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||
fireEvent.change(thresholdInput, { 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.specStalenessEnabled).toBe(true);
|
||
expect(payload.specStalenessMaxAgeMs).toBeUndefined();
|
||
});
|
||
|
||
it("Disabling Specification Staleness retains configured max age", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
specStalenessEnabled: true,
|
||
specStalenessMaxAgeMs: 8 * 60 * 60 * 1000,
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||
expect(checkbox.checked).toBe(true);
|
||
|
||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||
expect(thresholdInput.value).toBe("8");
|
||
expect(thresholdInput).not.toBeDisabled();
|
||
|
||
// Disable the toggle
|
||
fireEvent.click(checkbox);
|
||
expect(checkbox.checked).toBe(false);
|
||
expect(thresholdInput).toBeDisabled();
|
||
|
||
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.specStalenessEnabled).toBe(false);
|
||
expect(payload.specStalenessMaxAgeMs).toBe(8 * 60 * 60 * 1000);
|
||
});
|
||
|
||
it("Specification Staleness helper text is visible", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
expect(screen.getByText(/Maximum age in hours before a specification is considered stale/)).toBeTruthy();
|
||
expect(screen.getByText(/When enabled, tasks with stale specifications/)).toBeTruthy();
|
||
});
|
||
|
||
it("scope banners render for global and project sections with theme-aware icons", async () => {
|
||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Authentication is first (no scope banner) → should show no scope banner
|
||
expect(container.querySelector(".settings-scope-project")).toBeNull();
|
||
expect(container.querySelector(".settings-scope-global")).toBeNull();
|
||
|
||
// Switch to Appearance → should show global banner with Globe icon (SVG, not emoji)
|
||
fireEvent.click(screen.getAllByText("Appearance")[0]);
|
||
const globalBanner = container.querySelector(".settings-scope-global");
|
||
expect(globalBanner).toBeTruthy();
|
||
expect(globalBanner?.textContent).toContain("Fusion");
|
||
expect(container.querySelector(".settings-scope-project")).toBeNull();
|
||
// Verify banner uses SVG icon, not emoji
|
||
const globalIcon = globalBanner!.querySelector(".settings-scope-icon svg");
|
||
expect(globalIcon).toBeTruthy();
|
||
|
||
// Switch to General → should show project banner with Folder icon (SVG, not emoji)
|
||
fireEvent.click(screen.getAllByText("General")[0]);
|
||
const projectBanner = container.querySelector(".settings-scope-project");
|
||
expect(projectBanner).toBeTruthy();
|
||
expect(container.querySelector(".settings-scope-global")).toBeNull();
|
||
// Verify banner uses SVG icon, not emoji
|
||
const projectIcon = projectBanner!.querySelector(".settings-scope-icon svg");
|
||
expect(projectIcon).toBeTruthy();
|
||
|
||
// Switch to Models → should show global scope banner (Models is now a global-only section)
|
||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||
const modelsBanner = container.querySelector(".settings-scope-global");
|
||
expect(modelsBanner).toBeTruthy();
|
||
expect(modelsBanner?.textContent).toContain("Fusion");
|
||
expect(container.querySelector(".settings-scope-project")).toBeNull();
|
||
// Verify Models banner uses Globe icon as SVG element
|
||
const modelsIcon = modelsBanner!.querySelector(".settings-scope-icon svg");
|
||
expect(modelsIcon).toBeTruthy();
|
||
|
||
// Switch to Project Models → should show project scope banner
|
||
fireEvent.click(screen.getByText("Project Models"));
|
||
const projectModelsBanner = container.querySelector(".settings-scope-project");
|
||
expect(projectModelsBanner).toBeTruthy();
|
||
expect(projectModelsBanner?.textContent).toContain("project");
|
||
expect(container.querySelector(".settings-scope-global")).toBeNull();
|
||
// Verify Project Models banner uses Folder icon as SVG element
|
||
const projectModelsIcon = projectModelsBanner!.querySelector(".settings-scope-icon svg");
|
||
expect(projectModelsIcon).toBeTruthy();
|
||
});
|
||
|
||
// --- Settings save error handling tests ---
|
||
|
||
it("shows error toast when settings save fails", async () => {
|
||
(updateSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Failed to save settings"));
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Failed to save settings", "error"));
|
||
|
||
// Modal should stay open on error
|
||
expect(onClose).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("shows error toast when global settings save fails", async () => {
|
||
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Failed to save global settings"));
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Switch to Models section
|
||
fireEvent.click(screen.getByText("Models"));
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Failed to save global settings", "error"));
|
||
|
||
// Modal should stay open on error
|
||
expect(onClose).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("closes modal and shows success toast when settings save succeeds", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Settings saved", "success"));
|
||
|
||
// Modal should close on success
|
||
expect(onClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it("handles network error during settings save", async () => {
|
||
(updateSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Network error", "error"));
|
||
});
|
||
|
||
it("handles 500 server error during settings save", async () => {
|
||
(updateSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Internal server error"));
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Internal server error", "error"));
|
||
});
|
||
|
||
// --- Step Execution field tests (in Scheduling section) ---
|
||
|
||
it("shows runStepsInNewSessions checkbox and maxParallelSteps input in Scheduling section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Run each step in a new session");
|
||
expect(checkbox).toBeTruthy();
|
||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||
|
||
const input = screen.getByLabelText("Maximum parallel steps");
|
||
expect(input).toBeTruthy();
|
||
expect(input.getAttribute("type")).toBe("number");
|
||
});
|
||
|
||
it("maxParallelSteps input is disabled when runStepsInNewSessions is false", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const input = screen.getByLabelText("Maximum parallel steps") as HTMLInputElement;
|
||
expect(input.disabled).toBe(true);
|
||
});
|
||
|
||
it("toggling runStepsInNewSessions to true enables the maxParallelSteps input", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Run each step in a new session");
|
||
fireEvent.click(checkbox);
|
||
|
||
const input = screen.getByLabelText("Maximum parallel steps") as HTMLInputElement;
|
||
expect(input.disabled).toBe(false);
|
||
});
|
||
|
||
it("saving with runStepsInNewSessions true includes both fields in save payload", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getByText("Scheduling"));
|
||
const checkbox = screen.getByLabelText("Run each step in a new session");
|
||
fireEvent.click(checkbox);
|
||
|
||
const input = screen.getByLabelText("Maximum parallel steps") as HTMLInputElement;
|
||
fireEvent.change(input, { target: { value: "3" } });
|
||
|
||
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.runStepsInNewSessions).toBe(true);
|
||
expect(payload.maxParallelSteps).toBe(3);
|
||
});
|
||
});
|
||
|
||
describe("Prompts section", () => {
|
||
it("renders the Prompts section in the sidebar", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
expect(screen.getAllByText("Prompts").length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it("shows AgentPromptsManager when Prompts section is selected", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Click on Prompts section in sidebar (first one is the nav item)
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Should show scope banner (project-scoped)
|
||
expect(screen.getByText("These settings only affect this project.")).toBeTruthy();
|
||
|
||
// Should show the AgentPromptsManager with tabs
|
||
expect(screen.getByTestId("tab-templates")).toBeTruthy();
|
||
expect(screen.getByTestId("tab-assignments")).toBeTruthy();
|
||
expect(screen.getByTestId("tab-overrides")).toBeTruthy();
|
||
});
|
||
|
||
it("shows built-in templates in Templates tab", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Templates tab should be active by default
|
||
expect(screen.getByTestId("tab-templates")).toHaveClass(/active/);
|
||
|
||
// Should show built-in templates
|
||
expect(screen.getByTestId("builtin-template-default-executor")).toBeTruthy();
|
||
expect(screen.getByTestId("builtin-template-default-triage")).toBeTruthy();
|
||
});
|
||
|
||
it("shows Assignments tab with role dropdowns", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Click Assignments tab
|
||
fireEvent.click(screen.getByTestId("tab-assignments"));
|
||
|
||
// Should show role assignment rows
|
||
expect(screen.getByTestId("assignment-executor")).toBeTruthy();
|
||
expect(screen.getByTestId("assignment-triage")).toBeTruthy();
|
||
expect(screen.getByTestId("assignment-reviewer")).toBeTruthy();
|
||
expect(screen.getByTestId("assignment-merger")).toBeTruthy();
|
||
});
|
||
|
||
it("shows Overrides tab with accordion items", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Click Overrides tab
|
||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||
|
||
// Should show override items (collapsed by default)
|
||
expect(screen.getByTestId("override-executor-welcome")).toBeTruthy();
|
||
expect(screen.getByTestId("override-triage-welcome")).toBeTruthy();
|
||
});
|
||
|
||
it("editing a prompt override includes override in save payload", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Click Overrides tab
|
||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||
|
||
// Expand the executor-welcome override by clicking the expand button
|
||
fireEvent.click(screen.getByTestId("expand-executor-welcome"));
|
||
|
||
// Wait for the expanded editor to appear
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||
});
|
||
|
||
// Find the textarea for executor-welcome
|
||
const textarea = screen.getByTestId("override-input-executor-welcome") as HTMLTextAreaElement;
|
||
expect(textarea).toBeTruthy();
|
||
|
||
// Type custom content
|
||
fireEvent.change(textarea, { target: { value: "My custom executor welcome message" } });
|
||
expect(textarea.value).toBe("My custom executor welcome message");
|
||
|
||
// Save
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Verify the payload contains the override
|
||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.promptOverrides).toBeDefined();
|
||
expect(payload.promptOverrides["executor-welcome"]).toBe("My custom executor welcome message");
|
||
});
|
||
|
||
it("resetting an existing override sends null for that prompt key", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
promptOverrides: {
|
||
"executor-welcome": "Custom override text",
|
||
},
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Click Overrides tab
|
||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||
|
||
// Expand the executor-welcome override by clicking the expand button
|
||
fireEvent.click(screen.getByTestId("expand-executor-welcome"));
|
||
|
||
// Wait for the expanded editor to appear
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||
});
|
||
|
||
// Find and click the Reset button
|
||
fireEvent.click(screen.getByTestId("reset-executor-welcome"));
|
||
|
||
// Save
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Verify the payload contains null for the key
|
||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.promptOverrides).toBeDefined();
|
||
expect(payload.promptOverrides["executor-welcome"]).toBeNull();
|
||
});
|
||
|
||
it("promptOverrides are sent as project settings (not global)", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Click Overrides tab
|
||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||
|
||
// Expand the executor-welcome override by clicking the expand button
|
||
fireEvent.click(screen.getByTestId("expand-executor-welcome"));
|
||
|
||
// Wait for the expanded editor to appear
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||
});
|
||
|
||
// Find the textarea and type content
|
||
const textarea = screen.getByTestId("override-input-executor-welcome") as HTMLTextAreaElement;
|
||
fireEvent.change(textarea, { target: { value: "Custom message" } });
|
||
|
||
// Save
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Verify promptOverrides is in the project patch (updateSettings), not global
|
||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(projectPayload.promptOverrides).toBeDefined();
|
||
expect(projectPayload.promptOverrides["executor-welcome"]).toBe("Custom message");
|
||
|
||
// Verify global settings may be called (for ntfyEvents etc), but promptOverrides should NOT be in global
|
||
if (updateGlobalSettings.mock.calls.length > 0) {
|
||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(globalPayload.promptOverrides).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
it("shows customized badge and Reset button for existing overrides", async () => {
|
||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
promptOverrides: {
|
||
"executor-welcome": "Custom override text",
|
||
},
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Click Overrides tab
|
||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||
|
||
// Expand the executor-welcome override by clicking the expand button
|
||
const expandBtn = screen.getByTestId("expand-executor-welcome");
|
||
fireEvent.click(expandBtn);
|
||
|
||
// Wait for the expanded editor to appear
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||
});
|
||
|
||
// Should show "customized" badge
|
||
expect(screen.getByText("customized")).toBeTruthy();
|
||
|
||
// Should show Reset button
|
||
expect(screen.getByTestId("reset-executor-welcome")).toBeTruthy();
|
||
});
|
||
|
||
it("can create a custom template", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Templates tab should be active by default
|
||
expect(screen.getByTestId("tab-templates")).toHaveClass(/active/);
|
||
|
||
// Click "Add Custom Template" button
|
||
fireEvent.click(screen.getByTestId("add-template-btn"));
|
||
|
||
// Should show the template editor
|
||
expect(screen.getByTestId("template-editor")).toBeTruthy();
|
||
expect(screen.getByTestId("template-name-input")).toBeTruthy();
|
||
expect(screen.getByTestId("template-description-input")).toBeTruthy();
|
||
expect(screen.getByTestId("template-role-select")).toBeTruthy();
|
||
expect(screen.getByTestId("template-prompt-input")).toBeTruthy();
|
||
});
|
||
|
||
it("saving with agentPrompts includes it in the save payload", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||
|
||
// Click "Add Custom Template" button
|
||
fireEvent.click(screen.getByTestId("add-template-btn"));
|
||
|
||
// Fill in the template
|
||
fireEvent.change(screen.getByTestId("template-name-input"), { target: { value: "My Custom Template" } });
|
||
fireEvent.change(screen.getByTestId("template-description-input"), { target: { value: "A custom template description" } });
|
||
fireEvent.change(screen.getByTestId("template-prompt-input"), { target: { value: "Custom prompt text" } });
|
||
|
||
// Save the template
|
||
fireEvent.click(screen.getByTestId("save-template-btn"));
|
||
|
||
// Save the settings
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||
|
||
// Verify the payload contains agentPrompts with the custom template
|
||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(payload.agentPrompts).toBeDefined();
|
||
expect(payload.agentPrompts.templates).toBeDefined();
|
||
expect(payload.agentPrompts.templates.length).toBe(1);
|
||
expect(payload.agentPrompts.templates[0].name).toBe("My Custom Template");
|
||
});
|
||
|
||
describe("Reopen onboarding guide", () => {
|
||
it("renders Reopen onboarding guide button in Authentication section when onReopenOnboarding is provided", async () => {
|
||
const onReopenOnboarding = vi.fn();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} onReopenOnboarding={onReopenOnboarding} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Authentication section
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// Check that the reopen button is rendered
|
||
expect(screen.getByText("Reopen onboarding guide")).toBeTruthy();
|
||
});
|
||
|
||
it("does not render Reopen onboarding guide button when onReopenOnboarding is not provided", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Authentication section
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// Check that the reopen button is NOT rendered
|
||
expect(screen.queryByText("Reopen onboarding guide")).toBeNull();
|
||
});
|
||
|
||
it("calls onReopenOnboarding when Reopen button is clicked", async () => {
|
||
const onReopenOnboarding = vi.fn();
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} onReopenOnboarding={onReopenOnboarding} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Authentication section
|
||
fireEvent.click(screen.getAllByText("Authentication")[0]);
|
||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||
|
||
// Click the reopen button
|
||
fireEvent.click(screen.getByText("Reopen onboarding guide"));
|
||
|
||
// Verify callback was called
|
||
expect(onReopenOnboarding).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Regression tests for FN-1712: Settings UI model-lane scope UX.
|
||
*
|
||
* These tests guard the scope-split behavior introduced in FN-1712:
|
||
* - Each model setting is rendered as a lane with dual controls (global baseline + project override)
|
||
* - Inheritance indicators show whether a lane is overridden or inherited
|
||
* - Save payloads are split by scope: global keys go to updateGlobalSettings, project keys to updateSettings
|
||
* - Reset/clear interactions use null-as-delete semantics
|
||
*
|
||
* DOM selectors used:
|
||
* - Project model lanes: id="${laneId}Model" (e.g., "planningModel", "validatorModel")
|
||
* - Lane badges: .settings-lane-badge--override or .settings-lane-badge--inherited
|
||
* - Badge text: "Override (Project)" or "Inherited (Global)"
|
||
* - Reset button: text "Reset" in lane
|
||
* - Fallback models: id="planningFallbackModel", id="validatorFallbackModel"
|
||
* - Global models: id="defaultModel", id="fallbackModel", id="defaultThinkingLevel"
|
||
*/
|
||
|
||
describe("Model lane rendering (FN-1712 scope UX)", () => {
|
||
it("renders Project Models section with all three model lanes", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Project Models section
|
||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Verify all three model lanes are rendered with labels
|
||
expect(screen.getByLabelText("Planning Model")).toBeTruthy();
|
||
expect(screen.getByLabelText("Validator Model")).toBeTruthy();
|
||
expect(screen.getByLabelText("Title Summarization Model")).toBeTruthy();
|
||
});
|
||
|
||
it("renders Default Model and Fallback Model in global Models section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Navigate to Models section (global)
|
||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Verify default and fallback model dropdowns
|
||
expect(screen.getByLabelText("Default Model")).toBeTruthy();
|
||
expect(screen.getByLabelText("Fallback Model")).toBeTruthy();
|
||
});
|
||
|
||
it("renders Thinking Effort select in global Models section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Thinking Effort is a native select
|
||
const select = screen.getByLabelText("Thinking Effort");
|
||
expect(select.tagName).toBe("SELECT");
|
||
|
||
// Verify expected options
|
||
const options = Array.from(select.querySelectorAll("option")).map((o) => o.textContent);
|
||
expect(options).toContain("Default");
|
||
expect(options).toContain("Off");
|
||
expect(options).toContain("Minimal");
|
||
expect(options).toContain("Low");
|
||
expect(options).toContain("Medium");
|
||
expect(options).toContain("High");
|
||
});
|
||
|
||
it("renders Planning Fallback and Validator Fallback in project Models section", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Verify fallback model dropdowns
|
||
expect(screen.getByLabelText("Planning Fallback Model")).toBeTruthy();
|
||
expect(screen.getByLabelText("Validator Fallback Model")).toBeTruthy();
|
||
});
|
||
|
||
it("shows Inherited badge when project override is not set", async () => {
|
||
// Mock fetchSettingsByScope to return project with no overrides
|
||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
global: { themeMode: "dark", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
||
project: {},
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
await waitFor(() => expect(fetchSettingsByScope).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Planning lane should show Inherited badge
|
||
const planningLabel = screen.getByLabelText("Planning Model").closest(".form-group");
|
||
expect(planningLabel?.querySelector(".settings-lane-badge--inherited")).toBeTruthy();
|
||
expect(planningLabel?.textContent).toContain("Inherited (Global)");
|
||
});
|
||
|
||
it("shows Override badge when project override is set", async () => {
|
||
// Mock fetchSettingsByScope to return project with override
|
||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
global: { themeMode: "dark", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
||
project: { planningProvider: "openai", planningModelId: "gpt-4o" },
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
await waitFor(() => expect(fetchSettingsByScope).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Planning lane should show Override badge
|
||
const planningLabel = screen.getByLabelText("Planning Model").closest(".form-group");
|
||
expect(planningLabel?.querySelector(".settings-lane-badge--override")).toBeTruthy();
|
||
expect(planningLabel?.textContent).toContain("Override (Project)");
|
||
|
||
// Should show Reset button when overridden
|
||
expect(planningLabel?.textContent).toContain("Reset");
|
||
});
|
||
|
||
it("shows fallback text when both global and project are unset (automatic selection)", async () => {
|
||
// Mock fetchSettingsByScope to return empty values
|
||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
global: { themeMode: "dark" }, // No defaultProvider/defaultModelId set
|
||
project: {}, // No override set
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
await waitFor(() => expect(fetchSettingsByScope).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Default Model dropdown should show placeholder
|
||
const dropdown = screen.getByLabelText("Default Model");
|
||
expect(dropdown.textContent || dropdown.getAttribute("value")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe("Save payload scope-split (FN-1712)", () => {
|
||
it("global-only change calls updateGlobalSettings but not updateSettings for global keys", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open the Default Model dropdown and select a model
|
||
const user = userEvent.setup();
|
||
const dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||
await user.click(dropdownBtn);
|
||
|
||
// Select a model from the dropdown
|
||
const option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||
await user.click(option);
|
||
|
||
// Save
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => {
|
||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||
});
|
||
|
||
// updateSettings should NOT be called with global keys
|
||
const updateSettingsCalls = (updateSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||
const globalSettingsCalls = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||
|
||
// Verify global settings was called with defaultProvider/defaultModelId
|
||
expect(globalSettingsCalls.length).toBeGreaterThan(0);
|
||
const globalPayload = globalSettingsCalls[globalSettingsCalls.length - 1][0];
|
||
expect(globalPayload).toHaveProperty("defaultProvider");
|
||
expect(globalPayload).toHaveProperty("defaultModelId");
|
||
|
||
// Verify project settings was NOT called with global keys (or check it doesn't contain them)
|
||
if (updateSettingsCalls.length > 0) {
|
||
const projectPayload = updateSettingsCalls[updateSettingsCalls.length - 1][0];
|
||
expect(projectPayload.defaultProvider).toBeUndefined();
|
||
expect(projectPayload.defaultModelId).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
it("project-only change calls updateSettings but not updateGlobalSettings for project keys", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open Planning Model dropdown
|
||
const user = userEvent.setup();
|
||
const dropdownBtn = screen.getByRole("button", { name: /planning model/i });
|
||
await user.click(dropdownBtn);
|
||
|
||
// Select a model
|
||
const option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||
await user.click(option);
|
||
|
||
// Save
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => {
|
||
expect(updateSettings).toHaveBeenCalled();
|
||
});
|
||
|
||
// updateGlobalSettings should NOT be called with planning keys
|
||
const updateGlobalSettingsCalls = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||
const updateSettingsCalls = (updateSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||
|
||
// Verify project settings was called with planningProvider/planningModelId
|
||
expect(updateSettingsCalls.length).toBeGreaterThan(0);
|
||
const projectPayload = updateSettingsCalls[updateSettingsCalls.length - 1][0];
|
||
expect(projectPayload).toHaveProperty("planningProvider");
|
||
expect(projectPayload).toHaveProperty("planningModelId");
|
||
|
||
// Verify global settings was NOT called with planning keys
|
||
if (updateGlobalSettingsCalls.length > 0) {
|
||
const globalPayload = updateGlobalSettingsCalls[updateGlobalSettingsCalls.length - 1][0];
|
||
expect(globalPayload.planningProvider).toBeUndefined();
|
||
expect(globalPayload.planningModelId).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
it("mixed global and project changes call both endpoints with correct subsets", async () => {
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
|
||
// Change global model
|
||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
let user = userEvent.setup();
|
||
let dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||
await user.click(dropdownBtn);
|
||
let option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||
await user.click(option);
|
||
|
||
// Change project model
|
||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
dropdownBtn = screen.getByRole("button", { name: /planning model/i });
|
||
await user.click(dropdownBtn);
|
||
option = await screen.findByRole("option", { name: /claude/i });
|
||
await user.click(option);
|
||
|
||
// Save
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => {
|
||
expect(updateSettings).toHaveBeenCalled();
|
||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||
});
|
||
|
||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
|
||
// Verify global payload contains only global keys
|
||
expect(globalPayload).toHaveProperty("defaultProvider");
|
||
expect(globalPayload).toHaveProperty("defaultModelId");
|
||
expect(globalPayload.planningProvider).toBeUndefined();
|
||
expect(globalPayload.planningModelId).toBeUndefined();
|
||
|
||
// Verify project payload contains only project keys
|
||
expect(projectPayload).toHaveProperty("planningProvider");
|
||
expect(projectPayload).toHaveProperty("planningModelId");
|
||
expect(projectPayload.defaultProvider).toBeUndefined();
|
||
expect(projectPayload.defaultModelId).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("Reset/clear null-as-delete semantics (FN-1712)", () => {
|
||
it("resetting a project override sends null to delete it", async () => {
|
||
// Mock fetchSettingsByScope to return project with override
|
||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||
global: { themeMode: "dark", planningGlobalProvider: "anthropic", planningGlobalModelId: "claude-sonnet-4-5" },
|
||
project: { planningProvider: "openai", planningModelId: "gpt-4o" },
|
||
});
|
||
|
||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||
await waitFor(() => expect(fetchSettingsByScope).toHaveBeenCalled());
|
||
|
||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Should show Override badge and Reset button
|
||
const planningLabel = screen.getByLabelText("Planning Model").closest(".form-group");
|
||
expect(planningLabel?.textContent).toContain("Override (Project)");
|
||
|
||
// Click Reset button - find the button with "Reset" text within the planning lane
|
||
const resetBtn = screen.getByRole("button", { name: "Reset" });
|
||
expect(resetBtn).toBeTruthy();
|
||
fireEvent.click(resetBtn);
|
||
|
||
// Save
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => {
|
||
expect(updateSettings).toHaveBeenCalled();
|
||
});
|
||
|
||
// Verify null-as-delete: planningProvider and planningModelId should be null (not undefined)
|
||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(projectPayload.planningProvider).toBeNull();
|
||
expect(projectPayload.planningModelId).toBeNull();
|
||
});
|
||
|
||
it("clearing a global setting sends null to delete it", async () => {
|
||
// Set up initial global settings with defaultProvider/defaultModelId set
|
||
(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.getAllByText("Models")[0]);
|
||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||
|
||
// Open the Default Model dropdown
|
||
const user = userEvent.setup();
|
||
const dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||
await user.click(dropdownBtn);
|
||
|
||
// Select "Use default" option to clear
|
||
const useDefaultOption = await screen.findByRole("option", { name: /use default/i });
|
||
await user.click(useDefaultOption);
|
||
|
||
// Save
|
||
fireEvent.click(screen.getByText("Save"));
|
||
await waitFor(() => {
|
||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||
});
|
||
|
||
// Verify null-as-delete: defaultProvider and defaultModelId should be null when clearing an existing value
|
||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||
expect(globalPayload.defaultProvider).toBeNull();
|
||
expect(globalPayload.defaultModelId).toBeNull();
|
||
});
|
||
});
|
||
});
|