diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index f25d33f358..437857d3da 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -97,6 +97,11 @@ import { SETTINGS_SECTION_METADATA } from "../../src/shared/settings-sections"; // --------------------------------------------------------------------------- export const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count"; export const GITHUB_STAR_CACHE_TTL_MS = 15 * 60 * 1000; +/* +FNXC:SettingsAutoSave 2026-08-17-00:20: +Form-backed Settings persist after this debounce. Named so quality-lane tests flush the same interval instead of inventing a second timeout. +*/ +export const SETTINGS_AUTOSAVE_DEBOUNCE_MS = 500; const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked"; function isSlashPrefixedAbsolutePath(path: string): boolean { @@ -3639,7 +3644,7 @@ export function SettingsModal({ autoSaveTimerRef.current = setTimeout(() => { autoSaveTimerRef.current = null; void persistSettingsRef.current?.(); - }, 500); + }, SETTINGS_AUTOSAVE_DEBOUNCE_MS); return () => { if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); }; diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx index 6637d22695..cca4b5b898 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.scheduling-merge.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { act, cleanup, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { cleanup, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { EditorView } from "@codemirror/view"; import path from "path"; import { SettingsModal } from "../SettingsModal"; @@ -209,14 +209,11 @@ describe("SettingsModal", () => { }); /* - FNXC:SettingsModalTests 2026-08-16-04:24: - Every Scheduling-tab test navigates with `await screen.findByRole("button", { name: "Scheduling" })`, - not `getByRole` after `waitFor(fetchSettings called)`. The fetch being CALLED is a mount-time signal - that can precede the resolved-settings render commit, so under parallel-worker load the nav button is - not yet in the tree and a bare getByRole flakes (observed 2026-08-15 in a 12-file run: only the - floating-window shell had mounted). findByRole waits for the actual UI readiness with the default - timeout — a wait-mechanism fix, not a widened timeout. Nav always happens BEFORE any test arms fake - timers (findBy* deadlocks under vitest fake timers), so this is safe file-wide. + FNXC:SettingsModalTests 2026-08-17-00:20: + Scheduling-tab tests open `initialSection: "scheduling"` instead of remounting Authentication + and clicking the sidebar. The 2026-08-16 findByRole nav wait existed because a fetch-called + signal can precede the nav-button commit; skipping that click removes the flake surface and + the extra section render. Tests that still need nav (proving the sidebar item exists) keep it. */ describe("Scheduling overlap ignore paths", () => { /* @@ -230,12 +227,8 @@ describe("SettingsModal", () => { mockFetchSettings.mockResolvedValue(settingsWithoutThreshold); mockFetchSettingsByScope.mockResolvedValue({ global: settingsWithoutThreshold, project: {} }); - renderModal(); + renderModal({ initialSection: "scheduling" }); await waitForSettingsModalReady(); - await act(async () => { - await Promise.resolve(); - }); - await settingsModalUser.click(await screen.findByRole("button", { name: "Scheduling" })); const threshold = screen.getByLabelText("Consecutive tool failures") as HTMLInputElement; expect(threshold.value).toBe("1"); @@ -261,12 +254,8 @@ describe("SettingsModal", () => { project: { executorToolFailureThreshold: 4, engineerBacklogAutoClaim: false }, }); - renderModal(); + renderModal({ initialSection: "scheduling" }); await waitForSettingsModalReady(); - await act(async () => { - await Promise.resolve(); - }); - await settingsModalUser.click(await screen.findByRole("button", { name: "Scheduling" })); expect((screen.getByLabelText("Consecutive tool failures") as HTMLInputElement).value).toBe("4"); await settingsModalUser.click(screen.getByLabelText("Let engineer agents auto-claim backlog tasks")); @@ -283,10 +272,8 @@ describe("SettingsModal", () => { mockFetchSettings.mockResolvedValue(settingsWithoutHiddenDefault); mockFetchSettingsByScope.mockResolvedValue({ global: settingsWithoutHiddenDefault, project: {} }); - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).toBeChecked(); }); @@ -298,19 +285,15 @@ describe("SettingsModal", () => { }); mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: { ignoreHiddenOverlapPaths: false } }); - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); expect(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)).not.toBeChecked(); }); it("sends hidden overlap filtering false without disrupting explicit ignore paths", async () => { - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); // 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. Fake timers must be enabled BEFORE the mutating edit so the debounce lands on the fake clock. vi.useFakeTimers(); @@ -333,10 +316,8 @@ describe("SettingsModal", () => { }); mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: { ignoreHiddenOverlapPaths: false } }); - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); vi.useFakeTimers(); fireEvent.click(screen.getByLabelText(/ignore hidden dot paths in overlap checks/i)); @@ -353,20 +334,16 @@ describe("SettingsModal", () => { overlapIgnorePaths: ["docs/", "generated/*"], }); - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); expect(screen.getByDisplayValue("docs/")).toBeInTheDocument(); expect(screen.getByDisplayValue("generated/*")).toBeInTheDocument(); }); it("supports selecting ignore paths through the browse picker", async () => { - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); await settingsModalUser.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); @@ -377,10 +354,8 @@ describe("SettingsModal", () => { }); it("includes overlapIgnorePaths in save payload", async () => { - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); vi.useFakeTimers(); fireEvent.click(screen.getByRole("button", { name: /browse path for ignored overlap entry 1/i })); @@ -405,10 +380,8 @@ describe("SettingsModal", () => { heartbeatScopeDiscipline: "lite", }); - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); const select = screen.getByLabelText("Heartbeat Scope Discipline") as HTMLSelectElement; expect(select.value).toBe("lite"); @@ -434,10 +407,8 @@ describe("SettingsModal", () => { ...(engineerBacklogAutoClaim === undefined ? {} : { engineerBacklogAutoClaim }), }); - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); expect((screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement).checked).toBe(expectedChecked); }); @@ -448,10 +419,8 @@ describe("SettingsModal", () => { engineerBacklogAutoClaim: false, }); - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement; expect(toggle.checked).toBe(false); @@ -472,10 +441,8 @@ describe("SettingsModal", () => { engineerBacklogAutoClaim: true, }); - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); const toggle = screen.getByLabelText("Let engineer agents auto-claim backlog tasks") as HTMLInputElement; expect(toggle.checked).toBe(true); @@ -493,11 +460,8 @@ describe("SettingsModal", () => { describe("Number input clearing", () => { it("allows clearing maxConcurrent without leaving a stuck zero", async () => { - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - // Open Scheduling section - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement; expect(input).toBeDefined(); @@ -518,11 +482,8 @@ describe("SettingsModal", () => { */ it("allows clearing pollIntervalMs without leaving a stuck zero", async () => { - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - // Open Scheduling section - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement; expect(input).toBeDefined(); @@ -533,10 +494,8 @@ describe("SettingsModal", () => { }); it("allows configuring stale high fan-out escalation threshold in hours", async () => { - renderModal(); - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); - - fireEvent.click(await screen.findByRole("button", { name: "Scheduling" })); + renderModal({ initialSection: "scheduling" }); + await waitForSettingsModalReady(); const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement; expect(input).toBeDefined(); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx index dcc090b47a..6d8caf3948 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx @@ -4,7 +4,8 @@ import { act, render, screen, waitFor, cleanup, fireEvent } from "@testing-libra import userEvent from "@testing-library/user-event"; import fs from "fs"; import path from "path"; -import { SettingsModal } from "../SettingsModal"; +import { SettingsModal, SETTINGS_AUTOSAVE_DEBOUNCE_MS } from "../SettingsModal"; +import { SETTINGS_SECTION_METADATA } from "../../../src/shared/settings-sections"; import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots"; /* @@ -141,6 +142,14 @@ export function renderModal(props: Partial> ); } +/* +FNXC:SettingsModalTests 2026-08-17-00:20: +Quality-lane SettingsModal files stay the dashboard's slowest CI-run tests when they remount +Authentication and click the sidebar, and when waitFor polls at the default 50ms. Keep the +same readiness and persist assertions; poll faster and open the target section directly. +*/ +export const SETTINGS_MODAL_WAIT = { interval: 5, timeout: 2000 } as const; + export async function waitForSettingsModalReady() { /* FNXC:DashboardTests 2026-07-18-13:35: @@ -148,10 +157,20 @@ export async function waitForSettingsModalReady() { Loading… on the next paint (OAuth incomplete-toast test). Wait for Loading to clear so Authentication/section clicks do not race the initial settings fetch. */ - await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); + await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled(), SETTINGS_MODAL_WAIT); await waitFor(() => { expect(screen.queryByText("Loading…")).not.toBeInTheDocument(); - }); + }, SETTINGS_MODAL_WAIT); +} + +function settingsSectionIdFromNavLabel(section: string): ComponentProps["initialSection"] { + const match = SETTINGS_SECTION_METADATA.find( + (entry) => entry.label.toLowerCase() === section.toLowerCase(), + ); + if (!match) { + throw new Error(`Unknown Settings nav label: ${section}`); + } + return match.id as ComponentProps["initialSection"]; } export async function renderModalSection( @@ -186,9 +205,8 @@ export type PersistSettingInput = { }; export async function expectSettingPersists({ section, label, kind, value, scope, expectedKey }: PersistSettingInput) { - renderModal(); + renderModal({ initialSection: settingsSectionIdFromNavLabel(section) }); await waitForSettingsModalReady(); - fireEvent.click(screen.getByRole("button", { name: new RegExp(`^${section}$`, "i") })); const control = await screen.findByLabelText(label); if (kind === "checkbox") { @@ -207,14 +225,14 @@ export async function expectSettingPersists({ section, label, kind, value, scope fireEvent.click(document.querySelector(".modal-close") as HTMLButtonElement); if (scope === "global") { - await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalled()); + await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalled(), SETTINGS_MODAL_WAIT); expect(mockUpdateGlobalSettings).toHaveBeenCalledWith( expect.objectContaining({ [expectedKey]: value }), ); return; } - await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled(), SETTINGS_MODAL_WAIT); expect(mockUpdateSettings).toHaveBeenCalledWith( expect.objectContaining({ [expectedKey]: value }), undefined, @@ -258,7 +276,7 @@ again defensively in installSettingsModalEnv's afterEach. */ export async function flushSettingsAutoSave() { await act(async () => { - await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(SETTINGS_AUTOSAVE_DEBOUNCE_MS); }); }