Files
fusion/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts
gsxdsm bb0f693d79 FN-5751: fix dashboard auto-merge toggle race
Prevent rapid auto-merge toggles from desynchronizing dashboard settings state.

- Track auto-merge state in a ref so each toggle reads and writes the latest value
- Apply optimistic updates from the ref and roll back correctly on failed setting updates
- Add regression coverage for rapid double-toggle behavior in useAppSettings
- Add a patch changeset for @runfusion/fusion describing the dashboard blank-state fix

Files changed:
 .changeset/fn-5751-auto-merge-toggle.md            |  5 ++++
 .../app/hooks/__tests__/useAppSettings.test.ts     | 27 ++++++++++++++++++++++
 packages/dashboard/app/hooks/useAppSettings.ts     | 20 +++++++++++-----
 3 files changed, 46 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-5751

Fusion-Task-Lineage: 1a0090ff-4015-46b8-a974-3accbbdce919
2026-05-30 14:18:15 -07:00

297 lines
8.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAppSettings } from "../useAppSettings";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchConfig: vi.fn(),
fetchSettings: vi.fn(),
updateSettings: vi.fn(),
}));
const mockFetchConfig = vi.mocked(api.fetchConfig);
const mockFetchSettings = vi.mocked(api.fetchSettings);
const mockUpdateSettings = vi.mocked(api.updateSettings);
describe("useAppSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchConfig.mockResolvedValue({
maxConcurrent: 4,
rootDir: "/workspace/project",
});
mockFetchSettings.mockResolvedValue({
autoMerge: false,
globalPause: true,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
staleHighFanoutBlockerAgeThresholdMs: 7200000,
showQuickChatFAB: false,
capacityRiskBannerEnabled: false,
} as never);
mockUpdateSettings.mockResolvedValue({} as never);
});
it("loads settings state from API", async () => {
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.maxConcurrent).toBe(4);
expect(result.current.rootDir).toBe("/workspace/project");
expect(result.current.autoMerge).toBe(false);
expect(result.current.testMode).toBe(false);
expect(result.current.isTestMode).toBe(false);
expect(result.current.globalPaused).toBe(true);
expect(result.current.enginePaused).toBe(false);
expect(result.current.prAuthAvailable).toBe(true);
expect(result.current.settingsLoaded).toBe(true);
expect(result.current.taskStuckTimeoutMs).toBe(600000);
expect(result.current.staleHighFanoutBlockerAgeThresholdMs).toBe(7200000);
expect(result.current.showQuickChatFAB).toBe(false);
expect(result.current.capacityRiskBannerEnabled).toBe(false);
expect(result.current.capacityRiskTodoThreshold).toBe(20);
});
expect(mockFetchConfig).toHaveBeenCalledWith("proj_123");
expect(mockFetchSettings).toHaveBeenCalledWith("proj_123");
});
it("optimistically toggles autoMerge and persists to API", async () => {
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.autoMerge).toBe(false);
});
await act(async () => {
await result.current.toggleAutoMerge();
});
expect(result.current.autoMerge).toBe(true);
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
});
it("preserves consistent autoMerge state across rapid toggles", async () => {
const updateResolvers: Array<() => void> = [];
mockUpdateSettings.mockImplementation(
() => new Promise((resolve) => updateResolvers.push(() => resolve({} as never))),
);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.autoMerge).toBe(false);
});
await act(async () => {
const firstToggle = result.current.toggleAutoMerge();
const secondToggle = result.current.toggleAutoMerge();
expect(result.current.autoMerge).toBe(false);
updateResolvers.forEach((resolve) => resolve());
await Promise.all([firstToggle, secondToggle]);
});
expect(mockUpdateSettings).toHaveBeenNthCalledWith(1, { autoMerge: true }, "proj_123");
expect(mockUpdateSettings).toHaveBeenNthCalledWith(2, { autoMerge: false }, "proj_123");
expect(result.current.autoMerge).toBe(false);
});
it("rolls back optimistic state when toggle update fails", async () => {
mockUpdateSettings.mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.globalPaused).toBe(true);
});
await act(async () => {
await result.current.toggleGlobalPause();
});
expect(result.current.globalPaused).toBe(true);
expect(mockUpdateSettings).toHaveBeenCalledWith(
{ globalPause: false, globalPauseReason: undefined },
"proj_123",
);
});
it("sets globalPauseReason to manual when pausing", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: false,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.globalPaused).toBe(false);
});
await act(async () => {
await result.current.toggleGlobalPause();
});
expect(result.current.globalPaused).toBe(true);
expect(mockUpdateSettings).toHaveBeenCalledWith(
{ globalPause: true, globalPauseReason: "manual" },
"proj_123",
);
});
it("derives isTestMode from defaultProvider=mock", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: true,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
defaultProvider: "mock",
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.testMode).toBe(false);
expect(result.current.isTestMode).toBe(true);
});
});
it("propagates capacity risk settings from fetchSettings", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: true,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
capacityRiskBannerEnabled: true,
capacityRiskTodoThreshold: 30,
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.capacityRiskBannerEnabled).toBe(true);
expect(result.current.capacityRiskTodoThreshold).toBe(30);
});
});
it("refresh() re-fetches and updates state", async () => {
const { result } = renderHook(() => useAppSettings("proj_123"));
// Initial state from first mock
await waitFor(() => {
expect(result.current.showQuickChatFAB).toBe(false);
});
// Change mock to return different value
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: true,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: true,
} as never);
// Call refresh
await act(async () => {
await result.current.refresh();
});
// Verify state was updated
await waitFor(() => {
expect(result.current.showQuickChatFAB).toBe(true);
});
// Verify fetchSettings was called again with correct projectId
expect(mockFetchSettings).toHaveBeenCalledWith("proj_123");
});
it("refresh() tolerates partial fetch failure", async () => {
mockFetchConfig.mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useAppSettings("proj_123"));
// settings should still be set even though config failed
await waitFor(() => {
expect(result.current.autoMerge).toBe(false);
});
// config defaults remain (maxConcurrent stays at initial 2)
expect(result.current.maxConcurrent).toBe(2);
});
it("treats legacy experimentalFeatures.devServer as enabling Dev Server", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: false,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
experimentalFeatures: {
devServer: true,
},
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.devServerEnabled).toBe(true);
});
});
it("derives todosEnabled from experimentalFeatures.todoView", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: false,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
experimentalFeatures: {
todoView: true,
},
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.todosEnabled).toBe(true);
});
});
it("derives goalsEnabled from experimentalFeatures.goalsView", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: false,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
experimentalFeatures: {
goalsView: true,
},
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.goalsEnabled).toBe(true);
});
});
});