test(dashboard): open Settings sections without sidebar navigation (#3482)
## Summary The dashboard settings quality lane is one of the slowest CI-run packs. Most Scheduling cases remounted Authentication and clicked the sidebar just to reach fields they already know by `initialSection`. That extra render is also the flake surface that previously needed `findByRole` after settings fetch. This change opens the target section on first render, polls readiness at 5ms instead of 50ms, and names the 500ms auto-save debounce so fake-timer flushes stay locked to product behavior. Persist assertions are unchanged. ## Test plan - [x] `pnpm --filter @fusion/dashboard exec vitest run --project dashboard-app-quality-settings --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts'` — 330 passed - [ ] Confirm the settings quality lane still runs in `full-suite.yml` shard packing (`pnpm --filter @fusion/dashboard run test:quality:app:settings`) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Improved consistency and reliability of settings autosave behavior. * Streamlined scheduling settings validation to reduce timing-related test flakiness. * Improved settings readiness and persistence checks for more dependable results. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ComponentProps<typeof SettingsModal>>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
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<typeof SettingsModal>["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<typeof SettingsModal>["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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user