Files
fusion/packages/dashboard/app/components/SettingsModal.test.tsx
2026-04-15 04:15:57 -07:00

653 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsModal } from "./SettingsModal";
import type { SettingsExportData } from "../api";
// --- API mocks ---
const mockFetchSettings = vi.fn();
const mockFetchSettingsByScope = vi.fn();
const mockExportSettings = vi.fn();
const mockUpdateSettings = vi.fn();
const mockUpdateGlobalSettings = vi.fn();
const mockFetchAuthStatus = vi.fn();
const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn();
const mockFetchModels = vi.fn();
const mockTestNtfyNotification = vi.fn();
const mockFetchBackups = vi.fn();
const mockCreateBackup = vi.fn();
const mockImportSettings = vi.fn();
const mockFetchMemory = vi.fn();
const mockSaveMemory = vi.fn();
const mockFetchGlobalConcurrency = vi.fn();
const mockUpdateGlobalConcurrency = vi.fn();
const mockFetchMemoryBackendStatus = vi.fn();
vi.mock("../api", () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
exportSettings: (...args: unknown[]) => mockExportSettings(...args),
importSettings: (...args: unknown[]) => mockImportSettings(...args),
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
fetchMemory: (...args: unknown[]) => mockFetchMemory(...args),
saveMemory: (...args: unknown[]) => mockSaveMemory(...args),
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
}));
// Mock the hook
const mockUseMemoryBackendStatus = vi.fn();
vi.mock("../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
const noop = () => {};
const defaultSettings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
autoMerge: true,
mergeStrategy: "direct",
recycleWorktrees: false,
worktreeNaming: "random",
includeTaskIdInCommit: true,
worktreeInitCommand: "",
ntfyEnabled: false,
ntfyTopic: undefined,
};
function renderModal(props = {}) {
return render(
<SettingsModal
onClose={noop}
addToast={noop}
{...props}
/>
);
}
describe("SettingsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchSettings.mockResolvedValue(defaultSettings);
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
mockFetchMemory.mockResolvedValue({ content: "## Existing memory\n- Learned pattern" });
mockSaveMemory.mockResolvedValue({ success: true });
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
mockFetchMemoryBackendStatus.mockResolvedValue({
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
});
mockUseMemoryBackendStatus.mockReturnValue({
currentBackend: "file",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: true,
hasConflictResolution: false,
persistent: true,
},
availableBackends: ["file", "readonly", "qmd"],
loading: false,
error: null,
refresh: vi.fn(),
});
// jsdom doesn't provide URL.createObjectURL — polyfill it
if (!URL.createObjectURL) {
URL.createObjectURL = vi.fn(() => "blob:http://localhost/mock") as any;
}
if (!URL.revokeObjectURL) {
URL.revokeObjectURL = vi.fn() as any;
}
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("settings export filename", () => {
it("uses fusion-settings- prefix for exported filename", async () => {
const mockExportData: SettingsExportData = {
version: 1,
exportedAt: "2026-04-04T12:00:00.000Z",
global: undefined,
project: { maxConcurrent: 2 },
};
mockExportSettings.mockResolvedValue(mockExportData);
// Spy on createElement to capture the download link's filename
const originalCreateElement = document.createElement.bind(document);
const createdElements: { tagName: string; download: string; href: string }[] = [];
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
const el = originalCreateElement(tagName);
if (tagName.toLowerCase() === "a") {
// Capture the download attribute when set
const origDownloadDescriptor = Object.getOwnPropertyDescriptor(
HTMLAnchorElement.prototype,
"download"
);
Object.defineProperty(el, "download", {
set(v: string) {
createdElements.push({ tagName, download: v, href: (el as HTMLAnchorElement).href });
origDownloadDescriptor?.set?.call(el, v);
},
get() {
return origDownloadDescriptor?.get?.call(el) ?? "";
},
configurable: true,
});
}
return el;
});
// Mock URL.createObjectURL and revokeObjectURL
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:http://localhost/mock");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
renderModal();
// Wait for settings to load
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
// Find and click the Export button
const exportButton = screen.getByTitle("Export settings to JSON file");
expect(exportButton).toBeDefined();
fireEvent.click(exportButton);
await waitFor(() => {
expect(mockExportSettings).toHaveBeenCalled();
});
// Assert the filename uses fusion-settings- prefix
expect(createdElements.length).toBeGreaterThanOrEqual(1);
const anchorElement = createdElements[0];
expect(anchorElement.download).toMatch(/^fusion-settings-/);
expect(anchorElement.download).toMatch(/^fusion-settings-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json$/);
});
it("does not use kb-settings- prefix for exported filename", async () => {
const mockExportData: SettingsExportData = {
version: 1,
exportedAt: "2026-04-04T12:00:00.000Z",
global: undefined,
project: { maxConcurrent: 2 },
};
mockExportSettings.mockResolvedValue(mockExportData);
// Capture filenames set on dynamically-created anchor elements
const capturedFilenames: string[] = [];
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
const el = originalCreateElement(tagName);
if (tagName.toLowerCase() === "a") {
const origDownloadDescriptor = Object.getOwnPropertyDescriptor(
HTMLAnchorElement.prototype,
"download"
);
Object.defineProperty(el, "download", {
set(v: string) {
capturedFilenames.push(v);
origDownloadDescriptor?.set?.call(el, v);
},
get() {
return origDownloadDescriptor?.get?.call(el) ?? "";
},
configurable: true,
});
}
return el;
});
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:http://localhost/mock");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
fireEvent.click(screen.getByTitle("Export settings to JSON file"));
await waitFor(() => {
expect(mockExportSettings).toHaveBeenCalled();
});
// Negative assertion: filename must NOT use the old kb- prefix
expect(capturedFilenames.length).toBeGreaterThanOrEqual(1);
for (const filename of capturedFilenames) {
expect(filename).not.toMatch(/^kb-settings-/);
}
});
});
describe("Number input clearing", () => {
it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
// Open Scheduling section
fireEvent.click(screen.getByText("Scheduling"));
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
expect(input).toBeDefined();
// Clear the input - the input should be empty, not show "0"
await userEvent.clear(input);
expect(input.value).toBe("");
});
it("allows clearing globalMaxConcurrent without leaving a stuck zero", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
// Open Scheduling section
fireEvent.click(screen.getByText("Scheduling"));
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
expect(input).toBeDefined();
// Clear the input - the input should be empty, not show "0"
await userEvent.clear(input);
expect(input.value).toBe("");
});
it("allows clearing pollIntervalMs without leaving a stuck zero", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
// Open Scheduling section
fireEvent.click(screen.getByText("Scheduling"));
const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement;
expect(input).toBeDefined();
// Clear the input - the input should be empty, not show "0"
await userEvent.clear(input);
expect(input.value).toBe("");
});
it("allows clearing maxWorktrees without leaving a stuck zero", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
// Open Worktrees section
fireEvent.click(screen.getByText("Worktrees"));
const input = screen.getByLabelText("Max Worktrees") as HTMLInputElement;
expect(input).toBeDefined();
// Clear the input - the input should be empty, not show "0"
await userEvent.clear(input);
expect(input.value).toBe("");
});
});
describe("Memory section", () => {
it("renders the Memory section in the sidebar", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
expect(screen.getByText("Memory")).toBeDefined();
});
it("shows the memory toggle with default enabled", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
expect(checkbox).toBeDefined();
// Default is enabled, so checkbox should be checked
expect(checkbox).toBeChecked();
});
it("shows memory toggle unchecked when memoryEnabled is false", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
memoryEnabled: false,
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
expect(checkbox).toBeDefined();
expect(checkbox).not.toBeChecked();
});
it("toggles the memory setting when checkbox is clicked", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
expect(checkbox).toBeChecked();
// Uncheck it
await userEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
// Check it again
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
});
it("loads and shows memory editor content when navigating to Memory", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
expect(mockFetchMemory).not.toHaveBeenCalled();
await userEvent.click(screen.getByText("Memory"));
await waitFor(() => {
expect(mockFetchMemory).toHaveBeenCalledWith(undefined);
});
const editor = await screen.findByLabelText("Editor for .fusion/memory.md") as HTMLTextAreaElement;
expect(editor.value).toContain("Existing memory");
});
it("shows loading state while memory is being fetched", async () => {
let resolveMemory: ((value: { content: string }) => void) | undefined;
mockFetchMemory.mockReturnValueOnce(
new Promise<{ content: string }>((resolve) => {
resolveMemory = resolve;
})
);
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
expect(screen.getByText("Loading memory…")).toBeDefined();
resolveMemory?.({ content: "# Loaded" });
await waitFor(() => {
expect(screen.getByLabelText("Editor for .fusion/memory.md")).toBeDefined();
});
});
it("supports editing and saving memory content", async () => {
const addToast = vi.fn();
renderModal({ addToast });
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
const editor = await screen.findByLabelText("Editor for .fusion/memory.md");
fireEvent.change(editor, { target: { value: "# Updated memory\n- Reusable learning" } });
const saveButton = await screen.findByRole("button", { name: "Save Memory" });
await userEvent.click(saveButton);
await waitFor(() => {
expect(mockSaveMemory).toHaveBeenCalledWith("# Updated memory\n- Reusable learning", undefined);
});
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
});
it("handles empty memory content from API", async () => {
mockFetchMemory.mockResolvedValueOnce({ content: "" });
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
const editor = await screen.findByLabelText("Editor for .fusion/memory.md") as HTMLTextAreaElement;
expect(editor.value).toBe("");
});
});
describe("Experimental Features section", () => {
it("renders the Experimental Features section in the sidebar", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
expect(screen.getByText("Experimental Features")).toBeDefined();
});
it("shows empty state message when no experimental features are configured", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
expect(screen.getByText(/No experimental features configured/i)).toBeInTheDocument();
});
it("shows feature flags when experimentalFeatures is set", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": true, "another-feature": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
expect(screen.getByText("my-feature")).toBeInTheDocument();
expect(screen.getByText("another-feature")).toBeInTheDocument();
});
it("feature flags are unchecked when value is false", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it("feature flags are checked when value is true", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": true },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it("toggling a feature flag updates the form state", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
// Toggle it
await userEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
it("saving with toggled feature flag includes experimentalFeatures in payload", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
// Toggle the feature
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
await userEvent.click(checkbox);
// Save
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload.experimentalFeatures).toEqual({ "my-feature": true });
});
it("shows project scope banner in Experimental Features section", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
// Should show project scope indicator
expect(screen.getByText(/only affect this project/i)).toBeInTheDocument();
});
it("handles undefined experimentalFeatures (falls back to empty)", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: undefined,
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
// Should show empty state since undefined falls back to {}
expect(screen.getByText(/No experimental features configured/i)).toBeInTheDocument();
});
it("saves experimentalFeatures with multiple toggled flags", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "feature-a": true, "feature-b": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
// Toggle feature-b to true
const checkboxB = screen.getByLabelText("feature-b") as HTMLInputElement;
await userEvent.click(checkboxB);
// Save
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
});
});
});