import fs from "node:fs"; import { loadAllAppCss } from "../../test/cssFixture"; import path from "node:path"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SettingsModal } from "../SettingsModal"; import type { Settings } from "@fusion/core"; const defaultSettings = { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15_000, groupOverlappingFiles: false, autoMerge: true, mergeStrategy: "direct", directMergeCommitStrategy: "auto", pushAfterMerge: false, pushRemote: "origin", 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", "fallback-used", "memory-dreams-processed", "oauth-token-expired"], webhookEnabled: false, webhookUrl: undefined, webhookFormat: undefined, webhookEvents: undefined, taskStuckTimeoutMs: undefined, maxStuckKills: 6, runStepsInNewSessions: false, maxParallelSteps: 2, } as Settings; 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: [], favoriteProviders: [], favoriteModels: [] })), fetchCustomProviders: vi.fn(() => Promise.resolve({ providers: [] })), createCustomProvider: vi.fn(() => Promise.resolve({ provider: {} })), updateCustomProvider: vi.fn(() => Promise.resolve({ provider: {} })), deleteCustomProvider: vi.fn(() => Promise.resolve(undefined)), testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })), testNotification: vi.fn(() => Promise.resolve({ success: true })), fetchBackups: vi.fn(() => Promise.resolve({ count: 0, totalSize: 0, backups: [] })), 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 })), fetchMemoryFiles: vi.fn(() => Promise.resolve({ files: [ { path: ".fusion/memory/DREAMS.md", label: "Dreams", layer: "dreams", size: 0, updatedAt: "2026-04-17T12:00:00.000Z", }, { path: ".fusion/memory/MEMORY.md", label: "Long-term memory", layer: "long-term", size: 0, updatedAt: "2026-04-17T12:00:00.000Z", }, ], })), fetchMemoryFile: vi.fn((path = ".fusion/memory/DREAMS.md") => Promise.resolve({ path, content: "" })), saveMemoryFile: vi.fn(() => Promise.resolve({ success: true })), installQmd: vi.fn(() => Promise.resolve({ success: true, qmdAvailable: true, qmdInstallCommand: "bun install -g @tobilu/qmd" })), testMemoryRetrieval: vi.fn(() => Promise.resolve({ query: "project memory", qmdAvailable: true, usedFallback: false, qmdInstallCommand: "bun install -g @tobilu/qmd", results: [], })), fetchGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })), updateGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })), fetchMemoryBackendStatus: vi.fn(() => Promise.resolve({ currentBackend: "file", capabilities: { readable: true, writable: true, supportsAtomicWrite: true, hasConflictResolution: false, persistent: true, }, availableBackends: ["file", "readonly", "qmd"], qmdAvailable: true, qmdInstallCommand: "bun install -g @tobilu/qmd", })), fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })), })); vi.mock("../../hooks/useMemoryBackendStatus", () => ({ useMemoryBackendStatus: vi.fn(() => ({ status: { currentBackend: "qmd", capabilities: { readable: true, writable: true, supportsAtomicWrite: false, hasConflictResolution: false, persistent: true, }, availableBackends: ["file", "readonly", "qmd"], qmdAvailable: true, qmdInstallCommand: "bun install -g @tobilu/qmd", }, currentBackend: "file", capabilities: { readable: true, writable: true, supportsAtomicWrite: true, hasConflictResolution: false, persistent: true, }, availableBackends: ["file", "readonly", "qmd"], loading: false, error: null, refresh: vi.fn(), })), })); import { fetchSettings } from "../../api"; function mockSettingsViewport(matches: boolean): void { Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query: string) => ({ matches, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function expectMobileRule(css: string, selector: string, declaration: string): void { const pattern = new RegExp( `@media\\s*\\(max-width:\\s*768px\\)\\s*\\{[\\s\\S]*?${escapeRegExp(selector)}\\s*\\{[\\s\\S]*?${escapeRegExp(declaration)}`, ); expect(pattern.test(css)).toBe(true); } function expectBaseRule(css: string, selector: string, declaration: string): void { const pattern = new RegExp( `${escapeRegExp(selector)}\\s*\\{[^}]*${escapeRegExp(declaration)}`, ); expect(pattern.test(css)).toBe(true); } describe("SettingsModal mobile adaptations", () => { beforeEach(() => { vi.clearAllMocks(); mockSettingsViewport(false); }); it("renders mobile-targeted settings layout classes", async () => { const { container } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); expect(container.querySelector(".settings-layout")).toBeTruthy(); expect(container.querySelector(".settings-sidebar")).toBeTruthy(); expect(container.querySelector(".settings-content")).toBeTruthy(); }); it("renders the app version label in mobile layout", async () => { mockSettingsViewport(true); const { findByText } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); expect(await findByText("Version 1.2.3")).toBeTruthy(); }); it("excludes research sections from mobile picker when researchView is disabled", async () => { mockSettingsViewport(true); const user = userEvent.setup(); const { getByLabelText } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); const picker = getByLabelText("Settings Section") as HTMLSelectElement; expect(Array.from(picker.options).map((opt) => opt.value)).not.toContain("research-global"); expect(Array.from(picker.options).map((opt) => opt.value)).not.toContain("research-project"); await user.selectOptions(picker, "memory"); expect((picker as HTMLSelectElement).value).toBe("memory"); }); it("includes research sections in mobile picker when researchView is enabled", async () => { vi.mocked(fetchSettings).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { researchView: true }, }); mockSettingsViewport(true); const { getByLabelText } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); const picker = getByLabelText("Settings Section") as HTMLSelectElement; const optionValues = Array.from(picker.options).map((opt) => opt.value); expect(optionValues).toContain("research-global"); expect(optionValues).toContain("research-project"); }); it("can open memory settings from the mobile section picker", async () => { mockSettingsViewport(true); const user = userEvent.setup(); const { getByLabelText, findByText } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await user.selectOptions(getByLabelText("Settings Section"), "memory"); expect(await findByText(/Memory lives in/)).toBeTruthy(); expect(getByLabelText("Memory File")).toBeTruthy(); }); it("renders settings nav items with active class for touch styling", async () => { const { container } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); const navItems = container.querySelectorAll(".settings-nav-item"); expect(navItems.length).toBeGreaterThan(0); expect(container.querySelector(".settings-nav-item.active")).toBeTruthy(); }); it("renders form controls inside settings-content for 16px mobile targeting", async () => { const user = userEvent.setup(); const { container, findAllByText } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); // Authentication is first by default, so click General to see form controls const generalTabs = await findAllByText("General"); await user.click(generalTabs[0]); const controls = container.querySelectorAll(".settings-content input, .settings-content select, .settings-content textarea"); expect(controls.length).toBeGreaterThan(0); }); it("shows scope indicators and updates scope banner across sections", async () => { const user = userEvent.setup(); const { container, getByText, getAllByText } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); // Authentication is first with no scope banner by default - click the Project-scoped General section expect(container.querySelectorAll(".settings-scope-icon").length).toBeGreaterThan(0); await user.click(getByText("Project General")); // Verify project scope banner contains icon elements (SVG from Lucide, not emoji) const projectBanner = container.querySelector(".settings-scope-project"); expect(projectBanner).toBeTruthy(); const projectBannerIcon = projectBanner!.querySelector(".settings-scope-icon svg"); expect(projectBannerIcon).toBeTruthy(); expect(getByText("These settings only affect this project.")).toBeTruthy(); await user.click(getByText("Appearance")); // Verify global scope banner contains icon elements (SVG from Lucide, not emoji) const globalBanner = container.querySelector(".settings-scope-global"); expect(globalBanner).toBeTruthy(); const globalBannerIcon = globalBanner!.querySelector(".settings-scope-icon svg"); expect(globalBannerIcon).toBeTruthy(); expect(getByText("These settings are shared across all your Fusion projects.")).toBeTruthy(); }); it("renders notification provider cards responsively on mobile", async () => { mockSettingsViewport(true); Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 }); const user = userEvent.setup(); const { getByLabelText, findByText, container } = render(); await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await user.selectOptions(getByLabelText("Settings Section"), "notifications"); expect(await findByText("ntfy")).toBeTruthy(); expect(await findByText("Webhook")).toBeTruthy(); expect(container.querySelectorAll(".notification-provider-card").length).toBeGreaterThan(1); }); it("contains required mobile settings CSS overrides", () => { const css = loadAllAppCss(); expectMobileRule(css, ".settings-layout", "flex-direction: column;"); expectMobileRule(css, ".settings-mobile-section-picker", "display: flex;"); expectMobileRule(css, ".settings-sidebar", "display: none;"); expectMobileRule(css, ".settings-nav-item", "display: flex;"); expectMobileRule(css, ".settings-nav-item", "align-items: center;"); expectMobileRule(css, ".settings-nav-item", "justify-content: center;"); expectMobileRule(css, ".settings-nav-item", "gap: 4px;"); expectMobileRule(css, ".settings-content textarea", "font-size: 16px;"); expectMobileRule(css, ".settings-section-heading", "padding: var(--space-lg) 0 var(--space-md);"); expectMobileRule(css, ".settings-section-heading", "margin: 0;"); expectMobileRule(css, ".settings-scope-icon", "margin-right: 0;"); expectMobileRule(css, ".settings-scope-banner", "padding: var(--space-sm) var(--space-lg);"); expectMobileRule(css, ".settings-empty-state", "padding: 12px 14px;"); expectMobileRule(css, ".settings-description", "padding: 0 var(--space-lg);"); expectMobileRule(css, ".theme-selector", "padding: 0 14px 14px;"); expectMobileRule(css, ".settings-preset-item", "flex-direction: column;"); expectMobileRule(css, ".settings-preset-item-actions", "justify-content: flex-start;"); expectMobileRule(css, ".settings-preset-size-grid", "grid-template-columns: 1fr;"); expectMobileRule(css, ".auth-provider-header > div:not(.auth-provider-info):not(.auth-apikey-section)", "margin-left: auto;"); expectMobileRule(css, ".auth-apikey-section", "align-items: flex-end;"); expectMobileRule(css, ".auth-apikey-input-row", "justify-content: flex-end;"); expectMobileRule(css, ".auth-apikey-input-row .btn", "margin-left: auto;"); expectMobileRule(css, ".notification-provider-header", "padding: var(--space-sm) var(--space-md);"); expectMobileRule(css, ".notification-provider-body", "padding: var(--space-md);"); // Remote Access header elements must use the same mobile gutter as other remote blocks expectMobileRule(css, ".remote-status-bar", "margin: 0 var(--space-lg) var(--space-md);"); expectMobileRule(css, ".remote-share-block", "margin: 0 var(--space-lg) var(--space-md);"); // Base rules: desktop uses --space-xl horizontal margin for remote header elements expectBaseRule(css, ".remote-status-bar", "margin: 0 var(--space-xl) var(--space-md);"); expectBaseRule(css, ".remote-share-block", "margin: 0 var(--space-xl) var(--space-md);"); // Settings header actions keep Help/Star controls on a shared height contract on desktop; mobile inherits this height (FN-4354 reverted prior mobile inflation). expectBaseRule(css, ".settings-header-actions", "--settings-header-action-height: calc(var(--space-md) * 2 + var(--space-xs) / 2);"); expectBaseRule(css, ".settings-header-actions > .settings-header-help-btn", "height: var(--settings-header-action-height);"); }); it("FN-4354: settings header actions and modal-close have no mobile touch-target inflation", () => { const css = loadAllAppCss(); // FN-4354 regression guard: mobile settings header no longer inflates compact toolbar controls. const mobileBlockMatch = css.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.settings-modal \.modal-close\s*\{[\s\S]*?\}/); const mobileBlock = mobileBlockMatch?.[0] ?? ""; expect(css.includes("--settings-header-action-height: calc(var(--space-md) * 3)")).toBe(false); expect(css.includes("min-height: var(--settings-header-action-height)")).toBe(false); expect(/\.settings-header-actions\s*>\s*\.btn-icon\s*\{[\s\S]*?min-width:\s*calc\(var\(--space-md\)\s*\*\s*3\)/.test(css)).toBe(false); expect(/\.settings-modal \.modal-close\s*\{[^}]*min-height:/.test(mobileBlock)).toBe(false); expect(/\.settings-modal \.modal-close\s*\{[^}]*min-width:/.test(mobileBlock)).toBe(false); expectBaseRule(css, ".settings-header-actions", "--settings-header-action-height: calc(var(--space-md) * 2 + var(--space-xs) / 2);"); }); it("styles settings scrollbar rules for sidebar and content", () => { const css = loadAllAppCss(); expectBaseRule(css, ".settings-sidebar", "scrollbar-color: var(--border) transparent;"); expectBaseRule(css, ".settings-sidebar", "scrollbar-width: thin;"); expectBaseRule(css, ".settings-sidebar::-webkit-scrollbar", "width: 6px;"); expectBaseRule(css, ".settings-sidebar::-webkit-scrollbar-thumb", "background: var(--border);"); expectBaseRule(css, ".settings-sidebar::-webkit-scrollbar-thumb:hover", "background: var(--text-muted);"); expectBaseRule(css, ".settings-content", "scrollbar-color: var(--border) transparent;"); expectBaseRule(css, ".settings-content", "scrollbar-width: thin;"); expectBaseRule(css, ".settings-content::-webkit-scrollbar", "width: 6px;"); expectBaseRule(css, ".settings-content::-webkit-scrollbar-thumb", "background: var(--border);"); expectBaseRule(css, ".settings-content::-webkit-scrollbar-thumb:hover", "background: var(--text-muted);"); expectBaseRule(css, ".settings-section-heading", "padding: var(--space-lg) 0 var(--space-md);"); expectBaseRule(css, ".settings-section-heading", "margin: 0;"); expectBaseRule(css, ".settings-section-heading", "border-bottom: 1px solid var(--border);"); }); });