Slow-test trim, DELETE + dashboard OPTIMIZE tiers. Deletions follow the docs/testing.md taxonomy exactly — 40 tests removed across TaskDetailModal.rendering (134→129), QuickEntryBox (300→280), TerminalModal (247→240), ModelOnboardingModal (205→197), and AgentDetailView.settings (27→25): byte-identical field-presence twins, CSS-class permutation residue, and strict-subset interaction copies, with every FN-tagged describe and user-noticeable behavior kept and condensed matrices converted to it.each with all cases retained. The real payoff is the FN-2707 latency pass: SettingsModal's real 500ms auto-save debounce (and AgentDetailView's 700ms config debounce) now flush under fake timers via a shared flushSettingsAutoSave harness export — SettingsModal splits 102.3s→63.6s. Also fixed (not appeased) a pre-existing race the speedup exposed: nav-button lookups now findByRole after the settings promise resolves, file-wide. Verified: 1235 tests green across the touched set, tsconfig.app.json typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
857 lines
38 KiB
TypeScript
857 lines
38 KiB
TypeScript
/*
|
||
FNXC:DashboardTests 2026-08-09-00:08:
|
||
The "General · Project" describe block was extracted out of SettingsModal.general.test.tsx into this
|
||
sibling file so the dashboard component shard parallelizes it across workers instead of paying the
|
||
whole ~44s render cost in one sequential module (weekly velocity flagged general.test as the slowest
|
||
file at ~2m under suite contention). Same FN-5048 feedback-loop rationale as the original 231→4 split
|
||
in the harness header: vi.mock factories stay per test module and delegate to the shared harness mock
|
||
fns + env setup; assertions are unchanged.
|
||
*/
|
||
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
|
||
import { act, render, screen, fireEvent, waitFor, within, cleanup } from "@testing-library/react";
|
||
import path from "path";
|
||
import { SettingsModal } from "../SettingsModal";
|
||
import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss";
|
||
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,
|
||
mockFetchSystemInfo,
|
||
mockRequestSystemRestart,
|
||
mockFetchRemoteSettings,
|
||
mockUpdateRemoteSettings,
|
||
mockFetchRemoteStatus,
|
||
mockInstallCloudflared,
|
||
mockStartRemoteTunnel,
|
||
mockStopRemoteTunnel,
|
||
mockKillExternalTunnel,
|
||
mockRegenerateRemotePersistentToken,
|
||
mockGenerateShortLivedRemoteToken,
|
||
mockFetchRemoteQr,
|
||
mockFetchRemoteUrl,
|
||
mockTriggerMemoryDreams,
|
||
mockFetchPluginUiSlots,
|
||
mockFetchPlugins,
|
||
mockFetchDroidCliStatus,
|
||
mockSetDroidCliEnabled,
|
||
mockFetchCursorCliStatus,
|
||
mockSetCursorCliEnabled,
|
||
mockSetCursorCliBinaryPath,
|
||
mockUseWorkspaceFileBrowser,
|
||
mockConfirm,
|
||
mockUseWorktrunkInstallStatus,
|
||
mockUseMemoryBackendStatus,
|
||
mockUseMobileKeyboard,
|
||
settingsModalCss,
|
||
noop,
|
||
defaultSettings,
|
||
renderModal,
|
||
waitForSettingsModalReady,
|
||
settingsModalUser,
|
||
expectSettingPersists,
|
||
installSettingsModalEnv,
|
||
flushSettingsAutoSave,
|
||
} from "./SettingsModal.test-harness";
|
||
|
||
const mockListDiscussionCategories = vi.fn(async () => ({ categories: [] }));
|
||
let pluginLifecycleListener: ((event: MessageEvent) => void) | undefined;
|
||
const mockSubscribeSse = vi.fn((_url: string, options: { events?: Record<string, (event: MessageEvent) => void> }) => {
|
||
pluginLifecycleListener = options.events?.["plugin:lifecycle"];
|
||
return () => {};
|
||
});
|
||
|
||
vi.mock("../../sse-bus", () => ({
|
||
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
|
||
}));
|
||
|
||
vi.mock("../../api", async (importOriginal) => {
|
||
const { createDashboardApiMock } = await import("../../test/mockApi");
|
||
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
|
||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
||
listDiscussionCategories: (...args: unknown[]) => mockListDiscussionCategories(...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),
|
||
fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args),
|
||
requestSystemRestart: (...args: unknown[]) => mockRequestSystemRestart(...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),
|
||
fetchPlugins: (...args: unknown[]) => mockFetchPlugins(...args),
|
||
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
|
||
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
|
||
fetchCursorCliStatus: (...args: unknown[]) => mockFetchCursorCliStatus(...args),
|
||
setCursorCliEnabled: (...args: unknown[]) => mockSetCursorCliEnabled(...args),
|
||
setCursorCliBinaryPath: (...args: unknown[]) => mockSetCursorCliBinaryPath(...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) }),
|
||
}));
|
||
|
||
let viewportMode: "mobile" | "desktop" = "mobile";
|
||
|
||
vi.mock("../../hooks/useViewportMode", () => ({
|
||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||
isFullScreenSheetViewport: () => false,
|
||
isShortViewport: () => false,
|
||
getViewportMode: () => viewportMode,
|
||
isMobileViewport: () => viewportMode === "mobile",
|
||
isTabletTouchViewport: (mode?: string) => mode === "tablet",
|
||
useViewportMode: () => viewportMode,
|
||
}));
|
||
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", () => {
|
||
// Keep Advanced off by default so disclosure default/persist tests stay truthful.
|
||
installSettingsModalEnv({ advancedSettings: false });
|
||
|
||
afterEach(() => {
|
||
viewportMode = "mobile";
|
||
mockListDiscussionCategories.mockReset();
|
||
mockListDiscussionCategories.mockResolvedValue({ categories: [] });
|
||
});
|
||
|
||
describe("General · Project", () => {
|
||
it("renders the recommendation cap with the default and bounded numeric control", async () => {
|
||
renderModal({ initialSection: "general" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const input = screen.getByLabelText("Maximum recommendations per task") as HTMLInputElement;
|
||
expect(input.value).toBe("3");
|
||
expect(input).toHaveAttribute("min", "0");
|
||
expect(input).toHaveAttribute("max", "20");
|
||
});
|
||
|
||
it("populates the Discussion category selector from the report category route", async () => {
|
||
mockListDiscussionCategories.mockResolvedValue({ categories: [{ id: "DC_ideas", name: "Ideas", slug: "ideas" }] });
|
||
renderModal({ initialSection: "general" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const category = await screen.findByLabelText("Discussion category");
|
||
expect(category).toBeEnabled();
|
||
expect(screen.getByRole("option", { name: "Ideas" })).toHaveValue("DC_ideas");
|
||
// FNXC:SettingsModalTests 2026-08-16-03:46: flush the 500ms auto-save debounce on the fake clock instead of a real-timer waitFor (FN-2707); assertions unchanged.
|
||
vi.useFakeTimers();
|
||
fireEvent.change(category, { target: { value: "DC_ideas" } });
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({ reportDiscussionCategory: "DC_ideas" });
|
||
});
|
||
|
||
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("omits the retired ephemeral compatibility controls while preserving follow-up policy", async () => {
|
||
renderModal({ initialSection: "general" });
|
||
await waitForSettingsModalReady();
|
||
|
||
expect(screen.queryByLabelText("Use ephemeral task-worker agents")).not.toBeInTheDocument();
|
||
expect(screen.queryByTestId("settings-help-ephemeralAgentsEnabled")).not.toBeInTheDocument();
|
||
expect(document.getElementById("ephemeralAgentsEnabled")).toBeNull();
|
||
|
||
const policy = screen.getByLabelText("Ephemeral agent follow-up tasks");
|
||
expect(policy).toBeInTheDocument();
|
||
vi.useFakeTimers();
|
||
fireEvent.change(policy, { target: { value: "deny" } });
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
expect(mockUpdateSettings.mock.calls[0]?.[0]).toMatchObject({
|
||
ephemeralAgentTaskCreationPolicy: "deny",
|
||
});
|
||
});
|
||
|
||
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("reorders, adds, and removes mobile quick actions before save", async () => {
|
||
const onMobileNavPrimaryItemsChange = vi.fn();
|
||
renderModal({ initialSection: "general", onMobileNavPrimaryItemsChange });
|
||
await waitForSettingsModalReady();
|
||
|
||
fireEvent.click(screen.getAllByRole("button", { name: /later$/i })[0]);
|
||
expect(onMobileNavPrimaryItemsChange).toHaveBeenLastCalledWith(["tasks", "command-center", "agents", "missions", "chat", "mailbox"]);
|
||
const rows = Array.from(screen.getByRole("group", { name: "Mobile footer quick actions" }).querySelectorAll(".settings-field-label-row"));
|
||
expect(rows[0].textContent).toContain("tasks");
|
||
|
||
fireEvent.click(screen.getByLabelText("Remove chat"));
|
||
expect(onMobileNavPrimaryItemsChange).toHaveBeenLastCalledWith(["tasks", "command-center", "agents", "missions", "mailbox"]);
|
||
|
||
await settingsModalUser.selectOptions(screen.getByLabelText("Add quick action"), "git");
|
||
expect(onMobileNavPrimaryItemsChange).toHaveBeenLastCalledWith(["tasks", "command-center", "agents", "missions", "mailbox", "git"]);
|
||
|
||
fireEvent.click(screen.getByLabelText("Remove tasks"));
|
||
expect(onMobileNavPrimaryItemsChange).toHaveBeenLastCalledWith(["command-center", "agents", "missions", "mailbox", "git"]);
|
||
});
|
||
|
||
it("defaults task chats common-feed opt-in to unchecked", async () => {
|
||
renderModal({ initialSection: "general" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const toggle = screen.getByLabelText("Show task chats in common Chat feed") as HTMLInputElement;
|
||
expect(toggle).toBeInTheDocument();
|
||
expect(toggle.checked).toBe(false);
|
||
});
|
||
|
||
it.each<PersistSettingInput>([
|
||
{
|
||
section: "General · Project",
|
||
label: "Completion Documentation Automation",
|
||
kind: "select",
|
||
value: "changeset",
|
||
scope: "project",
|
||
expectedKey: "completionDocumentationMode",
|
||
},
|
||
{
|
||
section: "General · Project",
|
||
label: "Review Artifacts",
|
||
kind: "select",
|
||
value: "user-facing",
|
||
scope: "project",
|
||
expectedKey: "reviewArtifacts",
|
||
},
|
||
{
|
||
section: "General · Project",
|
||
label: "Auto-cleanup old chats",
|
||
kind: "select",
|
||
value: 14,
|
||
scope: "project",
|
||
expectedKey: "chatAutoCleanupDays",
|
||
},
|
||
{
|
||
section: "General · Project",
|
||
label: "Close Quick Chat on outside click",
|
||
kind: "checkbox",
|
||
value: false,
|
||
scope: "project",
|
||
expectedKey: "quickChatCloseOnOutsideClick",
|
||
},
|
||
{
|
||
section: "General · Project",
|
||
label: "Show task chats in common Chat feed",
|
||
kind: "checkbox",
|
||
value: true,
|
||
scope: "project",
|
||
expectedKey: "showTaskChatsInCommonFeed",
|
||
},
|
||
{
|
||
section: "General · Project",
|
||
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("persists report default and per-action filing mode overrides", async () => {
|
||
renderModal({ initialSection: "general" });
|
||
await waitForSettingsModalReady();
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.change(screen.getByLabelText("In-app report mode"), { target: { value: "auto-file" } });
|
||
fireEvent.change(screen.getByLabelText("Bug report override"), { target: { value: "draft-review" } });
|
||
fireEvent.click(screen.getByLabelText("Deduplicate reports against public roadmap"));
|
||
fireEvent.change(screen.getByLabelText("Public roadmap label"), { target: { value: "planned" } });
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||
expect(mockUpdateSettings.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
||
reportMode: "auto-file",
|
||
reportModeByAction: { bug: "draft-review" },
|
||
reportRoadmapDedupeEnabled: false,
|
||
reportRoadmapLabel: "planned",
|
||
}));
|
||
});
|
||
|
||
it("renders report-mode default help across unset, populated, and reset action overrides", async () => {
|
||
renderModal({ initialSection: "general" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const reportMode = screen.getByLabelText("In-app report mode") as HTMLSelectElement;
|
||
expect(reportMode).toHaveValue("draft-review");
|
||
expect(screen.getByTestId("settings-help-reportMode")).toBeInTheDocument();
|
||
expect(screen.getByText(/Default: draft-review \(operator reviews a draft before filing\)/i).closest(".settings-help-bubble")).toBeTruthy();
|
||
|
||
const actionLabels = ["Bug", "Feedback", "Idea", "Help"];
|
||
const actionSelects = actionLabels.map((action) => screen.getByLabelText(`${action} report override`) as HTMLSelectElement);
|
||
for (const select of actionSelects) expect(select).toHaveValue("");
|
||
for (const action of ["bug", "feedback", "idea", "help"]) {
|
||
expect(screen.getByTestId(`settings-help-reportModeByAction-${action}`)).toBeInTheDocument();
|
||
}
|
||
const overrideHelp = screen.getAllByText(/No default — unset actions inherit reportMode/i);
|
||
expect(overrideHelp).toHaveLength(4);
|
||
for (const help of overrideHelp) expect(help.closest(".settings-help-bubble")).toBeTruthy();
|
||
|
||
fireEvent.change(actionSelects[0], { target: { value: "auto-file" } });
|
||
expect(actionSelects[0]).toHaveValue("auto-file");
|
||
expect(screen.getAllByText(/No default — unset actions inherit reportMode/i)).toHaveLength(4);
|
||
|
||
fireEvent.change(actionSelects[0], { target: { value: "" } });
|
||
for (const select of actionSelects) expect(select).toHaveValue("");
|
||
expect(screen.getAllByText(/No default — unset actions inherit reportMode/i)).toHaveLength(4);
|
||
});
|
||
|
||
it("renders embedded PostgreSQL connection-cap help from the English locale", async () => {
|
||
renderModal({ initialSection: "backups-global" });
|
||
await waitForSettingsModalReady();
|
||
|
||
/*
|
||
FNXC:PostgresEmbedded 2026-07-22-23:55:
|
||
Issue #2411: the cap is schema-unset so the server can resolve a platform-aware
|
||
default (win32 150, else 500). The input therefore renders empty ("auto"), not 500.
|
||
*/
|
||
expect(screen.getByLabelText("Embedded PostgreSQL connection cap")).toHaveValue(null);
|
||
expect(screen.getByLabelText("Embedded PostgreSQL connection cap")).toHaveAttribute("placeholder", "auto");
|
||
expect(screen.getByTestId("settings-help-embeddedPostgresMaxConnections")).toBeInTheDocument();
|
||
expect(screen.getByText("Maximum server connections for Fusion's embedded PostgreSQL. Applies after restarting Fusion. Range: 32–2,000. Unset by default — Fusion picks 500, or 150 on Windows where each connection is a separate process and higher caps can crash backends. External PostgreSQL uses its provider's connection limit.").closest(".settings-help-bubble")).toBeTruthy();
|
||
});
|
||
|
||
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");
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.change(mailCleanupSelect, { target: { value: "7" } });
|
||
|
||
await flushSettingsAutoSave();
|
||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||
|
||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(payload.mailAutoCleanupDays).toBe(7);
|
||
|
||
mockUpdateSettings.mockClear();
|
||
|
||
fireEvent.change(mailCleanupSelect, { target: { value: "0" } });
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
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");
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.change(recentInput, { target: { value: "7" } });
|
||
fireEvent.change(fetchLimitInput, { target: { value: "60" } });
|
||
fireEvent.change(summaryMaxInput, { target: { value: "900" } });
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
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 Source Control section", async () => {
|
||
renderModal({ initialSection: "source-control" });
|
||
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__");
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.change(modeSelect, { target: { value: "new-tasks" } });
|
||
fireEvent.change(screen.getByPlaceholderText("owner/repo"), { target: { value: "octo/repo" } });
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
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("renders and saves GitLab URL configuration as project settings", async () => {
|
||
renderModal({ initialSection: "source-control" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const disclosure = screen.getByTestId("project-gitlab-configuration-disclosure");
|
||
expect(disclosure).not.toHaveAttribute("open");
|
||
const enableToggle = screen.getByLabelText("Enable GitLab integration") as HTMLInputElement;
|
||
expect(enableToggle.checked).toBe(true);
|
||
await settingsModalUser.click(within(disclosure).getByText("GitLab Configuration"));
|
||
expect(disclosure).toHaveAttribute("open");
|
||
|
||
expect(screen.getByRole("heading", { name: "GitLab Configuration" })).toBeInTheDocument();
|
||
expect(screen.getByText(/Blank uses GitLab.com or the global default/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/Blank derives <instance>\/api\/v4/i)).toBeInTheDocument();
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.change(screen.getByLabelText("GitLab instance URL"), { target: { value: " https://gitlab.example.com/gitlab/ " } });
|
||
fireEvent.change(screen.getByLabelText("GitLab API base URL (optional / advanced)"), { target: { value: " https://api.example.com/v4/ " } });
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||
|
||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(payload.gitlabInstanceUrl).toBe("https://gitlab.example.com/gitlab/");
|
||
expect(payload.gitlabApiBaseUrl).toBe("https://api.example.com/v4/");
|
||
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
|
||
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
||
expect(globalPayload.gitlabInstanceUrl).toBeUndefined();
|
||
expect(globalPayload.gitlabApiBaseUrl).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
it("saves project GitLab disabled state without clearing stored URLs", async () => {
|
||
mockFetchSettings.mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
gitlabEnabled: true,
|
||
gitlabInstanceUrl: "https://gitlab.example.com/gitlab",
|
||
gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4",
|
||
});
|
||
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||
global: defaultSettings,
|
||
project: {
|
||
gitlabEnabled: true,
|
||
gitlabInstanceUrl: "https://gitlab.example.com/gitlab",
|
||
gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4",
|
||
},
|
||
});
|
||
|
||
renderModal({ initialSection: "source-control" });
|
||
await waitForSettingsModalReady();
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.click(screen.getByLabelText("Enable GitLab integration"));
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||
|
||
expect(mockUpdateSettings.mock.calls[0][0]).toMatchObject({ gitlabEnabled: false });
|
||
expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty("gitlabInstanceUrl");
|
||
expect(mockUpdateSettings.mock.calls[0][0]).not.toHaveProperty("gitlabApiBaseUrl");
|
||
});
|
||
|
||
it("clears GitLab URL project overrides back to defaults", async () => {
|
||
mockFetchSettings.mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
gitlabInstanceUrl: "https://gitlab.example.com/gitlab",
|
||
gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4",
|
||
});
|
||
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||
global: defaultSettings,
|
||
project: {
|
||
gitlabInstanceUrl: "https://gitlab.example.com/gitlab",
|
||
gitlabApiBaseUrl: "https://gitlab.example.com/gitlab/api/v4",
|
||
},
|
||
});
|
||
|
||
renderModal({ initialSection: "source-control" });
|
||
await waitForSettingsModalReady();
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.change(screen.getByLabelText("GitLab instance URL"), { target: { value: "" } });
|
||
fireEvent.change(screen.getByLabelText("GitLab API base URL (optional / advanced)"), { target: { value: "" } });
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||
|
||
expect(mockUpdateSettings.mock.calls[0][0]).toMatchObject({
|
||
gitlabInstanceUrl: null,
|
||
gitlabApiBaseUrl: null,
|
||
});
|
||
});
|
||
|
||
it("renders and saves imported GitHub issue tracking linking as a project setting", async () => {
|
||
renderModal({ initialSection: "source-control" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const importLinkToggle = screen.getByLabelText(
|
||
"Always link imported GitHub issues to GitHub tracking",
|
||
) as HTMLInputElement;
|
||
expect(importLinkToggle.id).toBe("githubLinkImportedIssuesToTracking");
|
||
expect(importLinkToggle.checked).toBe(false);
|
||
expect(screen.getByText(/does not turn GitHub tracking on for ordinary new tasks/i)).toBeInTheDocument();
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.click(importLinkToggle);
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||
|
||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(payload.githubLinkImportedIssuesToTracking).toBe(true);
|
||
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
|
||
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
||
expect(globalPayload.githubLinkImportedIssuesToTracking).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
it("saves imported GitHub issue tracking linking as disabled", async () => {
|
||
mockFetchSettings.mockResolvedValueOnce({
|
||
...defaultSettings,
|
||
githubLinkImportedIssuesToTracking: true,
|
||
});
|
||
mockFetchSettingsByScope.mockResolvedValueOnce({
|
||
global: defaultSettings,
|
||
project: { githubLinkImportedIssuesToTracking: true },
|
||
});
|
||
|
||
renderModal({ initialSection: "source-control" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const importLinkToggle = screen.getByLabelText(
|
||
"Always link imported GitHub issues to GitHub tracking",
|
||
) as HTMLInputElement;
|
||
expect(importLinkToggle.checked).toBe(true);
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.click(importLinkToggle);
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||
|
||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(payload.githubLinkImportedIssuesToTracking).toBe(false);
|
||
if (mockUpdateGlobalSettings.mock.calls.length > 0) {
|
||
const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record<string, unknown>;
|
||
expect(globalPayload.githubLinkImportedIssuesToTracking).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: "source-control" });
|
||
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__");
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.change(modeSelect, { target: { value: "off" } });
|
||
fireEvent.change(screen.getByPlaceholderText("owner/repo"), { target: { value: "" } });
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
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: "source-control" });
|
||
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: "source-control" });
|
||
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: "source-control" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const dedupToggle = screen.getByLabelText(
|
||
"Search the tracking repo for likely duplicates before opening a new issue",
|
||
) as HTMLInputElement;
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.click(dedupToggle);
|
||
|
||
await flushSettingsAutoSave();
|
||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||
|
||
const firstPayload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(firstPayload.githubTrackingDedupEnabled).toBe(false);
|
||
|
||
mockUpdateSettings.mockClear();
|
||
|
||
fireEvent.click(dedupToggle);
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
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: "Models · Project" }));
|
||
|
||
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: "Models · Project" }));
|
||
|
||
expect(screen.queryByText(/model used for summarization now lives on the workflow/i)).not.toBeInTheDocument();
|
||
// FNXC:ProjectModels 2026-07-24-03:10: #2400 (e514e134d) replaced the
|
||
// per-phase moved-to-workflow NOTE with a real editable "Project workflow
|
||
// model lanes" section; assert the editor heading instead of the old copy.
|
||
expect(screen.getByText("Project workflow model lanes")).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: "source-control" });
|
||
await waitForSettingsModalReady();
|
||
|
||
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
|
||
expect(await within(repoSelect).findByRole("option", { name: "octo/repo" })).toBeInTheDocument();
|
||
|
||
vi.useFakeTimers();
|
||
fireEvent.change(repoSelect, { target: { value: "octo/repo" } });
|
||
|
||
await flushSettingsAutoSave();
|
||
vi.useRealTimers();
|
||
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: "source-control" });
|
||
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: "source-control" });
|
||
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();
|
||
});
|
||
});
|
||
});
|