Files
fusion/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx
gsxdsm f9f767c46a FN-7007: speed up SettingsModal interaction tests
Optimize the split SettingsModal test suite to avoid default user-event overhead while preserving interaction coverage.

- Add a shared no-delay SettingsModal test user with pointer-event tree checks disabled.
- Route split SettingsModal tests through the shared fast user and use fireEvent for simple form mutations.
- Shorten modal readiness checks to wait on settings loading instead of querying Save repeatedly.

Files changed:
 .../__tests__/SettingsModal.general.test.tsx       | 124 +++++++--------
 .../__tests__/SettingsModal.models-auth.test.tsx   | 136 ++++++++--------
 .../SettingsModal.remote-notifications.test.tsx    |  61 ++++----
 .../SettingsModal.scheduling-merge.test.tsx        | 174 ++++++++++-----------
 .../__tests__/SettingsModal.test-harness.tsx       |  32 ++--
 .../__tests__/SettingsModal.testMode.test.tsx      |   5 +-
 .../__tests__/SettingsModal.worktrunk.test.tsx     |   5 +-
 .../__tests__/SettingsModalNodeRouting.test.tsx    |   7 +-
 8 files changed, 273 insertions(+), 271 deletions(-)

Fusion-Task-Id: FN-7007

Fusion-Task-Lineage: 42cf8ce2-3da7-4607-8790-5bf46c07b417
2026-06-25 19:35:05 -07:00

1091 lines
47 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, within, cleanup } from "@testing-library/react";
import path from "path";
import { SettingsModal } from "../SettingsModal";
import {
mockFetchSettings,
mockFetchSettingsByScope,
mockExportSettings,
mockUpdateSettings,
mockUpdateGlobalSettings,
mockFetchAuthStatus,
mockLoginProvider,
mockLogoutProvider,
mockCancelProviderLogin,
mockSaveApiKey,
mockSubmitProviderManualCode,
mockFetchModels,
mockFetchWorkflow,
mockFetchWorkflowSettingValues,
mockUpdateWorkflowSettingValues,
mockFetchCustomProviders,
mockCreateCustomProvider,
mockUpdateCustomProvider,
mockDeleteCustomProvider,
mockTestNtfyNotification,
mockTestNotification,
mockFetchBackups,
mockCreateBackup,
mockImportSettings,
mockFetchMemoryFiles,
mockFetchMemoryFile,
mockSaveMemoryFile,
mockCompactMemory,
mockFetchGlobalConcurrency,
mockUpdateGlobalConcurrency,
mockFetchMemoryBackendStatus,
mockTestMemoryRetrieval,
mockInstallQmd,
mockFetchGitRemotes,
mockFetchGitRemotesDetailed,
mockFetchProjects,
mockFetchDashboardHealth,
mockCheckForUpdates,
mockInstallUpdate,
mockFetchRemoteSettings,
mockUpdateRemoteSettings,
mockFetchRemoteStatus,
mockInstallCloudflared,
mockStartRemoteTunnel,
mockStopRemoteTunnel,
mockKillExternalTunnel,
mockRegenerateRemotePersistentToken,
mockGenerateShortLivedRemoteToken,
mockFetchRemoteQr,
mockFetchRemoteUrl,
mockTriggerMemoryDreams,
mockFetchPluginUiSlots,
mockFetchDroidCliStatus,
mockSetDroidCliEnabled,
mockFetchCursorCliStatus,
mockSetCursorCliEnabled,
mockUseWorkspaceFileBrowser,
mockConfirm,
mockUseWorktrunkInstallStatus,
mockUseMemoryBackendStatus,
mockUseMobileKeyboard,
settingsModalCss,
noop,
defaultSettings,
renderModal,
waitForSettingsModalReady,
settingsModalUser,
expectSettingPersists,
installSettingsModalEnv,
} from "./SettingsModal.test-harness";
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../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),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchWorkflow: (...args: unknown[]) => mockFetchWorkflow(...args),
fetchWorkflowSettingValues: (...args: unknown[]) => mockFetchWorkflowSettingValues(...args),
updateWorkflowSettingValues: (...args: unknown[]) => mockUpdateWorkflowSettingValues(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
deleteCustomProvider: (...args: unknown[]) => mockDeleteCustomProvider(...args),
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
testNotification: (...args: unknown[]) => mockTestNotification(...args),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),
saveMemoryFile: (...args: unknown[]) => mockSaveMemoryFile(...args),
compactMemory: (...args: unknown[]) => mockCompactMemory(...args),
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
fetchGitRemotes: (...args: unknown[]) => mockFetchGitRemotes(...args),
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
fetchProjects: (...args: unknown[]) => mockFetchProjects(...args),
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
installCloudflared: (...args: unknown[]) => mockInstallCloudflared(...args),
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
killExternalTunnel: (...args: unknown[]) => mockKillExternalTunnel(...args),
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
triggerMemoryDreams: (...args: unknown[]) => mockTriggerMemoryDreams(...args),
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
});
});
// Mock the hook
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => "mobile",
isMobileViewport: () => true,
useViewportMode: () => "mobile",
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
Globe: () => <span data-testid="icon-globe" />,
Folder: () => <span data-testid="icon-folder" />,
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
Star: ({ size }: { size?: number }) => <span data-testid="icon-star" style={{ width: size, height: size }} />,
HelpCircle: ({ size }: { size?: number }) => <span data-testid="icon-help-circle" style={{ width: size, height: size }} />,
Loader2: ({ className }: { className?: string }) => <span data-testid="icon-loader2" className={className} />,
};
});
vi.mock("../PluginManager", () => ({
PluginManager: () => <div data-testid="plugin-manager">Plugin manager content</div>,
}));
vi.mock("../PiExtensionsManager", () => ({
PiExtensionsManager: () => <div data-testid="pi-extensions-manager">Pi extensions content</div>,
}));
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args),
}));
vi.mock("../../hooks/useWorktrunkInstallStatus", () => ({
useWorktrunkInstallStatus: (...args: unknown[]) => mockUseWorktrunkInstallStatus(...args),
}));
vi.mock("../FileBrowser", () => ({
FileBrowser: ({ onSelectFile }: { onSelectFile: (path: string) => void }) => (
<div data-testid="mock-overlap-file-browser">
<button type="button" onClick={() => onSelectFile("README.md")}>Select README.md</button>
</div>
),
}));
describe("SettingsModal", () => {
installSettingsModalEnv();
it("applies keyboard CSS variables when mobile keyboard is open", async () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
const { container } = renderModal();
await waitForSettingsModalReady();
const modal = container.querySelector(".settings-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
});
it("defaults to the global General section when no initialSection is provided", async () => {
render(
<SettingsModal
onClose={noop}
addToast={noop}
/>,
);
await waitForSettingsModalReady();
expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument();
});
it("honors an explicit initialSection override", async () => {
renderModal({ initialSection: "authentication" });
await waitForSettingsModalReady();
expect(screen.getByRole("button", { name: /^Authentication$/ })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
});
// FNXC:EmbeddedPresentation 2026-06-22-12:00:
// presentation="embedded" (SettingsView) was a zero-coverage branch. Assert the embedded contract via
// useEmbeddedPresentation: embedded root class present, region role (not dialog), no fixed .modal-overlay
// backdrop / modal close button, and Escape does NOT dismiss (navigated away via the left sidebar instead).
describe("embedded presentation", () => {
it("renders the embedded root class with region role and no modal overlay or close button", async () => {
const { container } = renderModal({ presentation: "embedded" });
await waitForSettingsModalReady();
expect(container.querySelector(".settings-embedded")).not.toBeNull();
expect(container.querySelector(".settings-modal--embedded")).not.toBeNull();
expect(screen.getByRole("region", { name: "Settings" })).toBeInTheDocument();
// No fixed full-screen overlay backdrop and no dialog role in embedded mode.
expect(container.querySelector(".settings-modal-overlay")).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("removes the embedded page outer inset and keeps content padding inside each settings screen", () => {
expect(settingsModalCss).toMatch(/\.settings-embedded\.right-dock-embedded-view\s*\{[^}]*padding:\s*0;/);
expect(settingsModalCss).toMatch(/\.settings-content\s*\{[^}]*padding:\s*var\(--space-md\) var\(--space-xl\) var\(--space-lg\);/);
expect(settingsModalCss).toMatch(/\.settings-section-heading\s*\{[^}]*padding:\s*var\(--space-lg\) 0 var\(--space-md\);/);
});
it("does not dismiss on Escape in embedded mode", async () => {
const onClose = vi.fn();
renderModal({ presentation: "embedded", onClose });
await waitForSettingsModalReady();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the overlay and Escape-to-close in modal mode", async () => {
const onClose = vi.fn();
const { container } = renderModal({ onClose });
await waitForSettingsModalReady();
expect(container.querySelector(".settings-modal-overlay")).not.toBeNull();
expect(container.querySelector(".settings-modal--embedded")).toBeNull();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});
it("maps the legacy pi-extensions initialSection alias to Plugins", async () => {
renderModal({ initialSection: "pi-extensions" });
await waitForSettingsModalReady();
expect(screen.getByRole("button", { name: /^Plugins$/ })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Plugins" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Pi Extensions" })).toHaveAttribute("aria-selected", "true");
expect(await screen.findByTestId("pi-extensions-manager")).toBeInTheDocument();
});
it("shows a Secrets entry in the settings nav", async () => {
renderModal();
await waitForSettingsModalReady();
expect(screen.getByRole("button", { name: "Secrets" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Manage secrets" })).not.toBeInTheDocument();
});
it("renders the SecretsView when the Secrets section is selected", async () => {
renderModal();
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: "Secrets" }));
expect(await screen.findByRole("button", { name: "Add Secret" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Manage secrets" })).not.toBeInTheDocument();
});
it("shows direct merge commit routing only for direct merges", async () => {
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, merger: { mode: "legacy" } });
mockFetchSettingsByScope.mockResolvedValueOnce({
global: { ...defaultSettings, merger: { mode: "legacy" } },
project: {},
});
renderModal();
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: /^Merge$/ }));
await settingsModalUser.selectOptions(screen.getByLabelText("AI merge"), "deterministic");
expect(screen.getByLabelText("Direct merge commit routing")).toHaveValue("auto");
await settingsModalUser.selectOptions(screen.getByLabelText("Auto-completion mode"), "pull-request");
expect(screen.queryByLabelText("Direct merge commit routing")).not.toBeInTheDocument();
});
it("defaults the integration worktree select to reuse-task-worktree when the server omits it", async () => {
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
merger: { mode: "legacy" },
mergeIntegrationWorktree: undefined,
});
mockFetchSettingsByScope.mockResolvedValueOnce({
global: { ...defaultSettings, merger: { mode: "legacy" } },
project: {},
});
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
await settingsModalUser.selectOptions(screen.getByLabelText("AI merge"), "deterministic");
expect(screen.getByLabelText("Integration worktree")).toHaveValue("reuse-task-worktree");
});
it("persists cwd-main through the save payload", async () => {
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, merger: { mode: "legacy" } });
mockFetchSettingsByScope.mockResolvedValueOnce({
global: { ...defaultSettings, merger: { mode: "legacy" } },
project: {},
});
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
await settingsModalUser.selectOptions(screen.getByLabelText("AI merge"), "deterministic");
await settingsModalUser.selectOptions(screen.getByLabelText("Integration worktree"), "cwd-main");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.mergeIntegrationWorktree).toBe("cwd-main");
});
it("does NOT render the warning banner when the integration worktree is reuse-task-worktree (default)", async () => {
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, merger: { mode: "legacy" } });
mockFetchSettingsByScope.mockResolvedValueOnce({
global: { ...defaultSettings, merger: { mode: "legacy" } },
project: {},
});
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
expect(screen.queryByTestId("merge-integration-worktree-warning")).toBeNull();
});
it("renders the warning banner when the legacy cwd-main mode is selected", async () => {
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, merger: { mode: "legacy" } });
mockFetchSettingsByScope.mockResolvedValueOnce({
global: { ...defaultSettings, merger: { mode: "legacy" } },
project: {},
});
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
await settingsModalUser.selectOptions(screen.getByLabelText("AI merge"), "deterministic");
await settingsModalUser.selectOptions(screen.getByLabelText("Integration worktree"), "cwd-main");
const warning = screen.getByTestId("merge-integration-worktree-warning");
expect(warning).toBeInTheDocument();
expect(warning).toHaveAttribute("role", "alert");
expect(warning).toHaveTextContent("Legacy");
expect(warning).toHaveTextContent("FN-5348");
});
it("removes the warning banner when switching back to reuse-task-worktree", async () => {
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
mergeIntegrationWorktree: "cwd-main",
merger: { mode: "deterministic" },
});
mockFetchSettingsByScope.mockResolvedValueOnce({
global: { ...defaultSettings, mergeIntegrationWorktree: "cwd-main", merger: { mode: "deterministic" } },
project: {},
});
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
await settingsModalUser.selectOptions(screen.getByLabelText("AI merge"), "deterministic");
expect(screen.getByTestId("merge-integration-worktree-warning")).toBeInTheDocument();
await settingsModalUser.selectOptions(screen.getByLabelText("Integration worktree"), "reuse-task-worktree");
expect(screen.queryByTestId("merge-integration-worktree-warning")).toBeNull();
});
it("persists the legacy sibling branch rename escape hatch in worktree settings", async () => {
renderModal();
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: /^Worktrees$/ }));
const checkbox = screen.getByRole("checkbox", { name: "Allow silent sibling branch rename during executor conflicts" });
expect(checkbox).not.toBeChecked();
await settingsModalUser.click(checkbox);
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({ executorAllowSiblingBranchRename: true }),
undefined,
);
});
expect(screen.getByText(/restores the legacy behavior/i)).toBeInTheDocument();
});
describe("deferred settings fetches", () => {
it("does not fetch global concurrency until Scheduling is selected", async () => {
renderModal();
await waitForSettingsModalReady();
expect(mockFetchGlobalConcurrency).not.toHaveBeenCalled();
await settingsModalUser.click(screen.getByRole("button", { name: /Scheduling/ }));
await waitFor(() => {
expect(mockFetchGlobalConcurrency).toHaveBeenCalledTimes(1);
});
});
it("disables concurrency inputs until their actual values load", async () => {
mockFetchGlobalConcurrency.mockReturnValue(new Promise(() => {}));
renderModal();
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: /Scheduling/ }));
expect(screen.getByLabelText("Global Max Concurrent")).toBeDisabled();
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeDisabled();
expect(screen.getByLabelText("Max Triage Concurrent")).toBeDisabled();
});
it("enables memory backend status hook only when Memory section is active", async () => {
renderModal();
await waitForSettingsModalReady();
expect(mockUseMemoryBackendStatus).toHaveBeenCalled();
const initialCallHasDisabled = mockUseMemoryBackendStatus.mock.calls.some(
(call) => call[0]?.enabled === false,
);
expect(initialCallHasDisabled).toBe(true);
await settingsModalUser.click(screen.getByRole("button", { name: /Memory/ }));
await waitFor(() => {
const enabledCallSeen = mockUseMemoryBackendStatus.mock.calls.some(
(call) => call[0]?.enabled === true,
);
expect(enabledCallSeen).toBe(true);
});
});
});
describe("Global General", () => {
// Read-only default-render assertions are merged into one rendered
// instance to avoid re-rendering the full modal per pure-display check.
it("renders default global logging fields, helper text, and tracking repo control", async () => {
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
// persistAgentToolOutput defaults to unchecked; Star-on-GitHub control absent.
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).not.toBeChecked();
expect(screen.queryByRole("checkbox", { name: /Show "Star on GitHub" button in Settings header/i })).toBeNull();
// thinking-log checkboxes default to unchecked.
expect(screen.getByRole("checkbox", { name: "Save AI thinking for permanent agents" })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" })).not.toBeChecked();
// Helper descriptions render as small text (not .settings-field-help).
expect(document.querySelector(".settings-field-help")).toBeNull();
const toolOutputHelper = screen.getByText(/When disabled, tool rows are still logged but detailed tool payloads are omitted/i);
expect(toolOutputHelper.closest("small")).toBeTruthy();
const thinkingHelper = screen.getByText(/Leave both thinking toggles off to keep the original default behavior/i);
expect(thinkingHelper.closest("small")).toBeTruthy();
// Global default tracking repo control + inheritance hint render.
expect(screen.getByRole("combobox", { name: "Global default tracking repo" })).toBeInTheDocument();
expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument();
});
it("reflects persisted checked value from global settings", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
persistAgentToolOutput: true,
});
mockFetchSettingsByScope.mockResolvedValue({
global: { ...defaultSettings, persistAgentToolOutput: true },
project: {},
});
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).toBeChecked();
});
it("reflects persisted unchecked value from global settings", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
persistAgentToolOutput: false,
});
mockFetchSettingsByScope.mockResolvedValue({
global: { ...defaultSettings, persistAgentToolOutput: false },
project: {},
});
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
expect(screen.getByRole("checkbox", { name: "Save tool output in agent logs" })).not.toBeChecked();
});
it("falls back to legacy thinking-log flag when granular fields are unset", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
persistAgentThinkingLog: true,
});
mockFetchSettingsByScope.mockResolvedValue({
global: { ...defaultSettings, persistAgentThinkingLog: true },
project: {},
});
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
expect(screen.getByRole("checkbox", { name: "Save AI thinking for permanent agents" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" })).toBeChecked();
});
it("saves persistAgentToolOutput only via global settings payload", async () => {
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("checkbox", { name: "Save tool output in agent logs" }));
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(globalPayload.persistAgentToolOutput).toBe(true);
if (mockUpdateSettings.mock.calls.length > 0) {
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(projectPayload.persistAgentToolOutput).toBeUndefined();
}
});
it("saves granular thinking-log flags only via global settings payload", async () => {
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("checkbox", { name: "Save AI thinking for permanent agents" }));
await settingsModalUser.click(screen.getByRole("checkbox", { name: "Save AI thinking for ephemeral / task-worker agents" }));
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(globalPayload.persistAgentThinkingLogPermanent).toBe(true);
expect(globalPayload.persistAgentThinkingLogEphemeral).toBe(true);
expect(globalPayload.persistAgentThinkingLog).toBeUndefined();
if (mockUpdateSettings.mock.calls.length > 0) {
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(projectPayload.persistAgentThinkingLogPermanent).toBeUndefined();
expect(projectPayload.persistAgentThinkingLogEphemeral).toBeUndefined();
expect(projectPayload.persistAgentThinkingLog).toBeUndefined();
}
});
it("saves global default tracking repo via global settings payload only", async () => {
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
mockFetchGitRemotes.mockResolvedValueOnce([{ name: "origin", owner: "octo", repo: "global-default", url: "https://github.com/octo/global-default.git" }]);
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
await waitFor(() => {
expect(screen.getByRole("combobox", { name: "Global default tracking repo" })).toHaveValue("__custom__");
});
await settingsModalUser.selectOptions(screen.getByRole("combobox", { name: "Global default tracking repo" }), "octo/global-default");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(globalPayload.githubTrackingDefaultRepo).toBe("octo/global-default");
if (mockUpdateSettings.mock.calls.length > 0) {
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(projectPayload.githubTrackingDefaultRepo).toBeUndefined();
}
});
it("shows global tracking repo error hint and keeps custom entry when lookups fail", async () => {
mockFetchProjects.mockRejectedValueOnce(new Error("no projects"));
renderModal({ initialSection: "global-general" });
await waitForSettingsModalReady();
expect(await screen.findByText(/Could not load project list/i)).toBeInTheDocument();
const control = screen.getByRole("combobox", { name: "Global default tracking repo" });
expect(screen.getByRole("option", { name: "Custom…" })).toBeInTheDocument();
await settingsModalUser.selectOptions(control, "__custom__");
expect(screen.getByPlaceholderText("owner/repo")).toBeInTheDocument();
});
});
it("renders and saves agent provisioning approval settings", async () => {
renderModal({ initialSection: "agent-permissions" });
await waitForSettingsModalReady();
expect(screen.getByRole("heading", { name: "Agent Provisioning Approvals" })).toBeInTheDocument();
await settingsModalUser.selectOptions(screen.getByLabelText("Approval mode"), "always");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0]?.[0] as {
agentProvisioning?: { approvalMode?: string };
};
expect(payload.agentProvisioning?.approvalMode).toBe("always");
});
describe("Project General", () => {
it("renders completion documentation automation control", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const select = screen.getByLabelText("Completion Documentation Automation") as HTMLSelectElement;
expect(select).toBeInTheDocument();
expect(select.value).toBe("off");
expect(screen.getByRole("option", { name: "Require changeset (.changeset/*.md)" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Require changelog update (existing changelog)" })).toBeInTheDocument();
});
it("reports Quick Chat launcher changes immediately before save", async () => {
const onQuickChatButtonModeChange = vi.fn();
renderModal({ initialSection: "general", onQuickChatButtonModeChange });
await waitForSettingsModalReady();
await settingsModalUser.selectOptions(screen.getByLabelText("Quick Chat launcher"), "footer");
expect(onQuickChatButtonModeChange).toHaveBeenCalledWith("footer");
});
it.each<PersistSettingInput>([
{
section: "Project General",
label: "Completion Documentation Automation",
kind: "select",
value: "changeset",
scope: "project",
expectedKey: "completionDocumentationMode",
},
{
section: "Project General",
label: "Auto-cleanup old chats",
kind: "select",
value: 14,
scope: "project",
expectedKey: "chatAutoCleanupDays",
},
{
section: "Project General",
label: "Operational log retention",
kind: "select",
value: 7,
scope: "project",
expectedKey: "operationalLogRetentionDays",
},
])("persists $expectedKey through the expected settings scope", async (input) => {
await expectSettingPersists(input);
});
it("saves ephemeral agent toggle in project settings payload", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const ephemeralToggle = screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement;
expect(ephemeralToggle.checked).toBe(true);
await settingsModalUser.click(ephemeralToggle);
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.ephemeralAgentsEnabled).toBe(false);
});
it("exposes the ephemeral agents toggle via the desktop Project General sidebar nav item", async () => {
renderModal();
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: "Project General" }));
const ephemeralToggle = screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement;
expect(ephemeralToggle).toBeInTheDocument();
expect(ephemeralToggle.checked).toBe(true);
});
it("exposes the ephemeral agents toggle via the mobile Settings Section picker", async () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
renderModal();
await waitForSettingsModalReady();
const sectionPicker = screen.getByLabelText("Settings Section") as HTMLSelectElement;
expect(sectionPicker).toBeInTheDocument();
const projectGeneralOption = sectionPicker.querySelector('option[value="general"]');
expect(projectGeneralOption).toBeInTheDocument();
expect(projectGeneralOption).toHaveTextContent("Project General");
await settingsModalUser.selectOptions(sectionPicker, "general");
const ephemeralToggle = screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement;
expect(ephemeralToggle).toBeInTheDocument();
expect(ephemeralToggle.checked).toBe(true);
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
it("keeps ephemeral agent toggle checked when upgrading settings omit the key", async () => {
const { ephemeralAgentsEnabled: _omitted, ...upgradeSettings } = defaultSettings;
mockFetchSettings.mockResolvedValueOnce(upgradeSettings);
mockFetchSettingsByScope.mockResolvedValueOnce({ global: defaultSettings, project: {} });
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const ephemeralToggle = screen.getByLabelText("Use ephemeral task-worker agents") as HTMLInputElement;
expect(ephemeralToggle.checked).toBe(true);
});
it("renders chat auto-cleanup retention with the default off value", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const cleanupSelect = screen.getByLabelText("Auto-cleanup old chats") as HTMLSelectElement;
expect(cleanupSelect.value).toBe("0");
});
it("renders and saves mail auto-prune retention", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const mailCleanupSelect = screen.getByLabelText("Auto-prune old mail") as HTMLSelectElement;
expect(mailCleanupSelect.value).toBe("0");
await settingsModalUser.selectOptions(mailCleanupSelect, "7");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.mailAutoCleanupDays).toBe(7);
mockUpdateSettings.mockClear();
await settingsModalUser.selectOptions(mailCleanupSelect, "0");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const offPayload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(offPayload.mailAutoCleanupDays).toBe(0);
});
it("renders and saves chat room compaction controls", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
expect(screen.getByRole("heading", { name: "Chat Rooms" })).toBeInTheDocument();
const recentInput = screen.getByLabelText("Recent verbatim room messages") as HTMLInputElement;
const fetchLimitInput = screen.getByLabelText("Room compaction fetch limit") as HTMLInputElement;
const summaryMaxInput = screen.getByLabelText("Room summary max characters") as HTMLInputElement;
expect(recentInput.placeholder).toBe("25");
expect(fetchLimitInput.placeholder).toBe("200");
expect(summaryMaxInput.placeholder).toBe("3000");
await settingsModalUser.type(recentInput, "7");
await settingsModalUser.type(fetchLimitInput, "60");
await settingsModalUser.type(summaryMaxInput, "900");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.chatRoomRecentVerbatimMessages).toBe(7);
expect(payload.chatRoomCompactionFetchLimit).toBe(60);
expect(payload.chatRoomSummaryMaxChars).toBe(900);
});
it("renders and saves GitHub tracking controls in the General section", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
expect(screen.getByRole("heading", { name: "GitHub Tracking" })).toBeInTheDocument();
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
expect(modeSelect.value).toBe("off");
expect(repoSelect.value).toBe("__custom__");
await settingsModalUser.selectOptions(modeSelect, "new-tasks");
await settingsModalUser.type(screen.getByPlaceholderText("owner/repo"), "octo/repo");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.githubTrackingEnabledByDefault).toBe(true);
expect(payload.githubTrackingDefaultRepo).toBe("octo/repo");
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
expect(globalPayload.githubTrackingDefaultRepo).toBeUndefined();
}
});
it("saves GitHub tracking defaults as disabled and clears the repo when emptied", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "octo/existing",
});
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
expect(modeSelect.value).toBe("new-tasks");
expect(repoSelect.value).toBe("__custom__");
await settingsModalUser.selectOptions(modeSelect, "off");
await settingsModalUser.clear(screen.getByPlaceholderText("owner/repo"));
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.githubTrackingEnabledByDefault).toBe(false);
expect(payload.githubTrackingDefaultRepo).toBeUndefined();
});
it("renders github dedup toggle as checked when project value is unset", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const dedupToggle = screen.getByLabelText(
"Search the tracking repo for likely duplicates before opening a new issue",
) as HTMLInputElement;
expect(dedupToggle.checked).toBe(true);
});
it("renders github dedup toggle as unchecked when explicitly disabled", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
githubTrackingDedupEnabled: false,
});
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const dedupToggle = screen.getByLabelText(
"Search the tracking repo for likely duplicates before opening a new issue",
) as HTMLInputElement;
expect(dedupToggle.checked).toBe(false);
});
it("saves github dedup toggle changes", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const dedupToggle = screen.getByLabelText(
"Search the tracking repo for likely duplicates before opening a new issue",
) as HTMLInputElement;
await settingsModalUser.click(dedupToggle);
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const firstPayload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(firstPayload.githubTrackingDedupEnabled).toBe(false);
mockUpdateSettings.mockClear();
await settingsModalUser.click(dedupToggle);
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const secondPayload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(secondPayload.githubTrackingDedupEnabled).toBe(true);
});
it("hides summarization model picker when summarization and default tracking are disabled", async () => {
renderModal({ initialSection: "models" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: "Project Models" }));
expect(screen.queryByText("Title, commit message, and GitHub tracking issue summarization model")).not.toBeInTheDocument();
});
it("does not show a moved-to-workflow note for the summarizer model when GitHub tracking defaults are on", async () => {
mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
githubTrackingEnabledByDefault: true,
});
renderModal({ initialSection: "models" });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: "Project Models" }));
expect(screen.queryByText(/model used for summarization now lives on the workflow/i)).not.toBeInTheDocument();
expect(screen.getByText(/per-phase model lanes \(execution, planning, reviewer, and their fallbacks\) now live on the workflow/i)).toBeInTheDocument();
});
it("picks a project repo suggestion and preserves label association", async () => {
mockFetchGitRemotes.mockResolvedValueOnce([
{ name: "origin", owner: "octo", repo: "repo", url: "https://github.com/octo/repo.git" },
]);
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
expect(await within(repoSelect).findByRole("option", { name: "octo/repo" })).toBeInTheDocument();
await settingsModalUser.selectOptions(repoSelect, "octo/repo");
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.githubTrackingDefaultRepo).toBe("octo/repo");
});
it("shows project tracking repo error hint and keeps custom entry when remotes fail", async () => {
mockFetchGitRemotes.mockRejectedValueOnce(new Error("remotes failed"));
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
expect(await screen.findByText(/Could not load detected remotes/i)).toBeInTheDocument();
const control = screen.getByRole("combobox", { name: "Project default tracking repo" });
await settingsModalUser.selectOptions(control, "__custom__");
expect(screen.getByPlaceholderText("owner/repo")).toBeInTheDocument();
});
it("always shows GitHub tracking summarization helper copy", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
expect(
screen.getByText(/Tracking issues use this task's title\. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models\./),
).toBeInTheDocument();
});
});
describe("Appearance", () => {
it("renders dashboard font size options with saved value", async () => {
const onDashboardFontScaleChange = vi.fn();
renderModal({ dashboardFontScalePct: 110, onDashboardFontScaleChange });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: /Appearance/ }));
const largeButton = screen.getByRole("button", { name: "Large" });
expect(largeButton).toHaveAttribute("aria-pressed", "true");
await settingsModalUser.click(screen.getByRole("button", { name: "Small" }));
expect(onDashboardFontScaleChange).toHaveBeenCalledWith(90);
});
it("saves dashboard font scale to global settings", async () => {
renderModal({ dashboardFontScalePct: 100 });
await waitForSettingsModalReady();
await settingsModalUser.click(screen.getByRole("button", { name: /Appearance/ }));
await settingsModalUser.click(screen.getByRole("button", { name: "Largest" }));
await settingsModalUser.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
expect(payload).toEqual(expect.objectContaining({ dashboardFontScalePct: 120 }));
});
});
});