FN-5936: fix auto-merge board stabilization on mobile
Keep the dashboard board visible when auto-merge toggles across viewport changes. - keep board stabilization subscribed to visualViewport resize events instead of removing the listener after the first resize - add regression coverage for Android, iOS, tablet, desktop, empty-column, rollback, and error-boundary auto-merge toggle paths - relax settings hydration assertions and expand app-settings tests for auto-merge rollback and coercion behavior Files changed: .changeset/fn-5936-auto-merge-mobile-fix.md | 7 + packages/dashboard/app/components/Board.tsx | 19 +- packages/dashboard/app/components/__tests__/App.test.tsx | 8 +- packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx | 418 +++++++++++++++++++++ packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts | 53 ++- 5 files changed, 489 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-5936 Fusion-Task-Lineage: 6e937d0e-dc60-4863-a884-001228dd0cd1
This commit is contained in:
7
.changeset/fn-5936-auto-merge-mobile-fix.md
Normal file
7
.changeset/fn-5936-auto-merge-mobile-fix.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix the dashboard auto-merge toggle blanking on mobile by keeping board stabilization tied to viewport events instead of a one-shot resize listener.
|
||||||
|
|
||||||
|
The in-review board now stays visible when auto-merge is toggled across Android mobile, iOS mobile, tablet, and desktop layouts, with regression coverage for populated and empty columns plus rollback and error-boundary paths.
|
||||||
@@ -236,25 +236,20 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
const handleViewportResize = () => {
|
||||||
|
scheduleStabilization();
|
||||||
|
};
|
||||||
|
|
||||||
scheduleStabilization();
|
scheduleStabilization();
|
||||||
window.addEventListener("pageshow", handlePageShow);
|
window.addEventListener("pageshow", handlePageShow);
|
||||||
|
if (typeof visualViewport?.addEventListener === "function") {
|
||||||
const visualViewport = window.visualViewport;
|
|
||||||
let handleViewportResize: (() => void) | null = null;
|
|
||||||
if (visualViewport) {
|
|
||||||
handleViewportResize = () => {
|
|
||||||
scheduleStabilization();
|
|
||||||
if (typeof visualViewport.removeEventListener === "function") {
|
|
||||||
visualViewport.removeEventListener("resize", handleViewportResize!);
|
|
||||||
}
|
|
||||||
handleViewportResize = null;
|
|
||||||
};
|
|
||||||
visualViewport.addEventListener("resize", handleViewportResize);
|
visualViewport.addEventListener("resize", handleViewportResize);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pageshow", handlePageShow);
|
window.removeEventListener("pageshow", handlePageShow);
|
||||||
if (handleViewportResize && typeof visualViewport?.removeEventListener === "function") {
|
if (typeof visualViewport?.removeEventListener === "function") {
|
||||||
visualViewport.removeEventListener("resize", handleViewportResize);
|
visualViewport.removeEventListener("resize", handleViewportResize);
|
||||||
}
|
}
|
||||||
if (rafId !== null) {
|
if (rafId !== null) {
|
||||||
|
|||||||
@@ -1652,9 +1652,11 @@ describe("App auto-open Settings on unauthenticated", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||||
|
|
||||||
// The Settings modal should be open showing Authentication content
|
// The Settings modal should be open showing Authentication content.
|
||||||
// fetchSettings is called twice: once by App useEffect, once by SettingsModal
|
// App and SettingsModal both hydrate settings, and follow-up refreshes may
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
|
// legitimately add another fetch during initialization; the invariant here
|
||||||
|
// is that settings hydration happened before Authentication content renders.
|
||||||
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
// Authentication section should be active — auth status is fetched when section is active
|
// Authentication section should be active — auth status is fetched when section is active
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import { Board } from "../Board";
|
||||||
|
import { PageErrorBoundary } from "../ErrorBoundary";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useBlockerFanout", () => ({
|
||||||
|
useBlockerFanout: () => new Map(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useConfirm", () => ({
|
||||||
|
useConfirm: () => ({ confirm: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useFlashOnIncrease", () => ({
|
||||||
|
useFlashOnIncrease: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../PluginSlot", () => ({
|
||||||
|
PluginSlot: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../QuickEntryBox", () => ({
|
||||||
|
QuickEntryBox: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../TaskCard", () => ({
|
||||||
|
TaskCard: ({ task, autoMergeEnabled }: { task: Task; autoMergeEnabled?: boolean }) => {
|
||||||
|
if (task.id === "FN-ERROR" && autoMergeEnabled === false) {
|
||||||
|
throw new Error("Auto-merge render failed");
|
||||||
|
}
|
||||||
|
return <div data-testid={`task-card-${task.id}`}>task:{task.id}:{String(autoMergeEnabled)}</div>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../WorktreeGroup", () => ({
|
||||||
|
WorktreeGroup: ({ label, autoMergeEnabled }: { label: string; autoMergeEnabled?: boolean }) => (
|
||||||
|
<div data-testid={`worktree-group-${label}`}>worktree:{String(autoMergeEnabled)}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function ensureMatchMedia() {
|
||||||
|
if (!window.matchMedia) {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockViewport(width: number) {
|
||||||
|
ensureMatchMedia();
|
||||||
|
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||||||
|
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||||
|
matches: query === "(max-width: 768px)" ? width <= 768 : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVisualViewport(scale = 1) {
|
||||||
|
const resizeListeners = new Set<() => void>();
|
||||||
|
return {
|
||||||
|
scale,
|
||||||
|
addEventListener: vi.fn((event: string, listener: () => void) => {
|
||||||
|
if (event === "resize") {
|
||||||
|
resizeListeners.add(listener);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn((event: string, listener: () => void) => {
|
||||||
|
if (event === "resize") {
|
||||||
|
resizeListeners.delete(listener);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
dispatchResize: () => {
|
||||||
|
for (const listener of [...resizeListeners]) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTask(id: string, column: Task["column"]): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
description: `${id} description`,
|
||||||
|
column,
|
||||||
|
status: column === "in-review" ? "in-review" : undefined,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2026-06-03T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BaseBoardHarness({
|
||||||
|
tasks,
|
||||||
|
autoMerge,
|
||||||
|
onToggleAutoMerge,
|
||||||
|
}: {
|
||||||
|
tasks: Task[];
|
||||||
|
autoMerge: boolean;
|
||||||
|
onToggleAutoMerge: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<Board
|
||||||
|
tasks={tasks}
|
||||||
|
maxConcurrent={2}
|
||||||
|
onMoveTask={vi.fn(async () => ({} as Task))}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
onQuickCreate={vi.fn(async () => undefined)}
|
||||||
|
onNewTask={vi.fn()}
|
||||||
|
autoMerge={autoMerge}
|
||||||
|
onToggleAutoMerge={onToggleAutoMerge}
|
||||||
|
globalPaused={false}
|
||||||
|
/>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoardHarness({ tasks, initialAutoMerge = true }: { tasks: Task[]; initialAutoMerge?: boolean }) {
|
||||||
|
const [autoMerge, setAutoMerge] = useState(initialAutoMerge);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBoardHarness
|
||||||
|
tasks={tasks}
|
||||||
|
autoMerge={autoMerge}
|
||||||
|
onToggleAutoMerge={() => setAutoMerge((current) => !current)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RollbackBoardHarness({ tasks }: { tasks: Task[] }) {
|
||||||
|
const [autoMerge, setAutoMerge] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBoardHarness
|
||||||
|
tasks={tasks}
|
||||||
|
autoMerge={autoMerge}
|
||||||
|
onToggleAutoMerge={async () => {
|
||||||
|
const previousAutoMerge = autoMerge;
|
||||||
|
const nextAutoMerge = !previousAutoMerge;
|
||||||
|
setAutoMerge(nextAutoMerge);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.reject(new Error("network"));
|
||||||
|
} catch {
|
||||||
|
setAutoMerge(previousAutoMerge);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installAnimationFrame() {
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||||
|
setTimeout(() => cb(0), 0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectBoardVisible() {
|
||||||
|
expect(document.querySelector("main.board")).not.toBeNull();
|
||||||
|
expect(screen.getByText("In Review")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("auto-merge toggle mobile blank regression", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the mobile board visible after an Android viewport resize triggered by toggling auto-merge", () => {
|
||||||
|
const viewportSpy = mockViewport(375);
|
||||||
|
const visualViewport = createVisualViewport(1);
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: visualViewport,
|
||||||
|
});
|
||||||
|
installAnimationFrame();
|
||||||
|
|
||||||
|
render(<BoardHarness tasks={[createTask("FN-5936", "in-review")]} />);
|
||||||
|
|
||||||
|
const board = document.querySelector("main.board") as HTMLElement;
|
||||||
|
expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true");
|
||||||
|
expectBoardVisible();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
board.scrollLeft = 240;
|
||||||
|
act(() => {
|
||||||
|
visualViewport.dispatchResize();
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
expect(board.scrollLeft).toBe(0);
|
||||||
|
|
||||||
|
board.scrollLeft = 240;
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("false");
|
||||||
|
|
||||||
|
board.scrollLeft = 240;
|
||||||
|
act(() => {
|
||||||
|
visualViewport.dispatchResize();
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expectBoardVisible();
|
||||||
|
expect(board.scrollLeft).toBe(0);
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips auto-merge on mobile Android with an empty in-review column without blanking", () => {
|
||||||
|
const viewportSpy = mockViewport(375);
|
||||||
|
const visualViewport = createVisualViewport(1);
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: visualViewport,
|
||||||
|
});
|
||||||
|
installAnimationFrame();
|
||||||
|
|
||||||
|
render(<BoardHarness tasks={[]} />);
|
||||||
|
const board = document.querySelector("main.board") as HTMLElement;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expectBoardVisible();
|
||||||
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(toggle).not.toBeChecked();
|
||||||
|
board.scrollLeft = 180;
|
||||||
|
act(() => {
|
||||||
|
visualViewport.dispatchResize();
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
expectBoardVisible();
|
||||||
|
expect(board.scrollLeft).toBe(0);
|
||||||
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
board.scrollLeft = 180;
|
||||||
|
act(() => {
|
||||||
|
visualViewport.dispatchResize();
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
expectBoardVisible();
|
||||||
|
expect(board.scrollLeft).toBe(0);
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps populated task-card and worktree surfaces visible when auto-merge toggles on mobile", () => {
|
||||||
|
const viewportSpy = mockViewport(375);
|
||||||
|
const visualViewport = createVisualViewport(1);
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: visualViewport,
|
||||||
|
});
|
||||||
|
installAnimationFrame();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<BoardHarness
|
||||||
|
tasks={[
|
||||||
|
createTask("FN-5936", "in-review"),
|
||||||
|
createTask("FN-IP", "in-progress"),
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true");
|
||||||
|
expect(screen.getByTestId("worktree-group-Unassigned")).toHaveTextContent("true");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("false");
|
||||||
|
expect(screen.getByTestId("worktree-group-Unassigned")).toHaveTextContent("false");
|
||||||
|
expectBoardVisible();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-anchors on the mobile iOS pageshow path after toggling auto-merge", () => {
|
||||||
|
const viewportSpy = mockViewport(375);
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: createVisualViewport(1.1),
|
||||||
|
});
|
||||||
|
installAnimationFrame();
|
||||||
|
|
||||||
|
render(<BoardHarness tasks={[createTask("FN-IOS", "in-review")]} />);
|
||||||
|
const board = document.querySelector("main.board") as HTMLElement;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
|
||||||
|
board.scrollLeft = 210;
|
||||||
|
|
||||||
|
const pageShow = new Event("pageshow") as PageTransitionEvent;
|
||||||
|
Object.defineProperty(pageShow, "persisted", { configurable: true, value: true });
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(pageShow);
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expectBoardVisible();
|
||||||
|
expect(board.scrollLeft).toBe(0);
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the board visible on tablet where the mobile stabilization effect is disabled", () => {
|
||||||
|
const viewportSpy = mockViewport(900);
|
||||||
|
installAnimationFrame();
|
||||||
|
|
||||||
|
render(<BoardHarness tasks={[createTask("FN-TABLET", "in-review")]} />);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expectBoardVisible();
|
||||||
|
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(toggle).not.toBeChecked();
|
||||||
|
expect(screen.getByTestId("task-card-FN-TABLET")).toHaveTextContent("false");
|
||||||
|
expectBoardVisible();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the board visible on desktop after toggling auto-merge", () => {
|
||||||
|
const viewportSpy = mockViewport(1280);
|
||||||
|
installAnimationFrame();
|
||||||
|
|
||||||
|
render(<BoardHarness tasks={[createTask("FN-DESKTOP", "in-review")]} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("task-card-FN-DESKTOP")).toHaveTextContent("false");
|
||||||
|
expectBoardVisible();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the mobile board visible when the toggle rolls back after an update failure", async () => {
|
||||||
|
const viewportSpy = mockViewport(375);
|
||||||
|
const visualViewport = createVisualViewport(1);
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: visualViewport,
|
||||||
|
});
|
||||||
|
installAnimationFrame();
|
||||||
|
|
||||||
|
render(<RollbackBoardHarness tasks={[createTask("FN-ROLLBACK", "in-review")]} />);
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expect(screen.getByTestId("task-card-FN-ROLLBACK")).toHaveTextContent("true");
|
||||||
|
expectBoardVisible();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a visible page error boundary fallback instead of a blank board when a board child throws", () => {
|
||||||
|
const viewportSpy = mockViewport(375);
|
||||||
|
const visualViewport = createVisualViewport(1);
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: visualViewport,
|
||||||
|
});
|
||||||
|
installAnimationFrame();
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
render(<BoardHarness tasks={[createTask("FN-ERROR", "in-review")]} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,7 +102,24 @@ describe("useAppSettings", () => {
|
|||||||
expect(result.current.autoMerge).toBe(false);
|
expect(result.current.autoMerge).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rolls back optimistic state when toggle update fails", async () => {
|
it("rolls back optimistic autoMerge state when toggle update fails", async () => {
|
||||||
|
mockUpdateSettings.mockRejectedValueOnce(new Error("network"));
|
||||||
|
|
||||||
|
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(false);
|
||||||
|
expect(mockUpdateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back optimistic state when global pause update fails", async () => {
|
||||||
mockUpdateSettings.mockRejectedValueOnce(new Error("network"));
|
mockUpdateSettings.mockRejectedValueOnce(new Error("network"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useAppSettings("proj_123"));
|
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||||
@@ -168,6 +185,40 @@ describe("useAppSettings", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coerces undefined autoMerge settings to false", async () => {
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
|
autoMerge: undefined,
|
||||||
|
globalPause: true,
|
||||||
|
enginePaused: false,
|
||||||
|
prAuthAvailable: true,
|
||||||
|
taskStuckTimeoutMs: 600000,
|
||||||
|
showQuickChatFAB: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.autoMerge).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces truthy non-boolean autoMerge settings to true", async () => {
|
||||||
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
|
autoMerge: "enabled",
|
||||||
|
globalPause: true,
|
||||||
|
enginePaused: false,
|
||||||
|
prAuthAvailable: true,
|
||||||
|
taskStuckTimeoutMs: 600000,
|
||||||
|
showQuickChatFAB: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAppSettings("proj_123"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.autoMerge).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("propagates capacity risk settings from fetchSettings", async () => {
|
it("propagates capacity risk settings from fetchSettings", async () => {
|
||||||
mockFetchSettings.mockResolvedValueOnce({
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
autoMerge: false,
|
autoMerge: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user