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:
gsxdsm
2026-08-17 15:02:39 -07:00
committed by GitHub
parent 3e6eea5421
commit 73a4335a9f
3 changed files with 68 additions and 86 deletions

View File

@@ -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);
};

View File

@@ -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();

View File

@@ -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);
});
}