Files
fusion/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx
Fusion 72c50ae747 feat(FN-3913): align settings modal header action button sizing
Fixes header action button sizing in the SettingsModal to improve visual alignment across desktop and mobile, with regression tests added to prevent future regressions.

Fusion-Task-Id: FN-3913
2026-05-10 00:01:42 -07:00

365 lines
17 KiB
TypeScript

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",
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"],
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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.
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);");
expectMobileRule(css, ".settings-header-actions", "--settings-header-action-height: calc(var(--space-md) * 3);");
expectMobileRule(css, ".settings-header-actions > .settings-header-help-btn", "min-height: var(--settings-header-action-height);");
});
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);");
});
});