FN-6005: add mobile auto-merge blank-screen regression coverage
Add broader regression coverage for the mobile auto-merge blank-screen dashboard issue. - expand the mobile integration test harness to cover viewport, keyboard, and mobile nav shell behavior - add regression cases for empty in-review states, detail panels, update rollbacks, and app-shell round trips - verify sibling render failures surface the page error boundary fallback instead of leaving a blank screen Files changed: packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx | 220 ++++++++++++++++++++- 1 file changed, 217 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6005 Fusion-Task-Lineage: 7cbba538-343b-4540-910f-08ba6ce7d183
This commit is contained in:
@@ -5,9 +5,11 @@ import type { Settings, Task } from "@fusion/core";
|
|||||||
import { Board } from "../Board";
|
import { Board } from "../Board";
|
||||||
import { PageErrorBoundary } from "../ErrorBoundary";
|
import { PageErrorBoundary } from "../ErrorBoundary";
|
||||||
import { TaskReviewTab } from "../TaskReviewTab";
|
import { TaskReviewTab } from "../TaskReviewTab";
|
||||||
|
import { MobileNavBar } from "../MobileNavBar";
|
||||||
import { RetryWarningProvider } from "../../context/RetryWarningContext";
|
import { RetryWarningProvider } from "../../context/RetryWarningContext";
|
||||||
import { useAppSettings } from "../../hooks/useAppSettings";
|
import { useAppSettings } from "../../hooks/useAppSettings";
|
||||||
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
|
import { useMobileKeyboard, _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||||
|
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../../hooks/useViewportMode";
|
||||||
import { fetchConfig, fetchSettings, fetchTaskReview, updateSettings } from "../../api";
|
import { fetchConfig, fetchSettings, fetchTaskReview, updateSettings } from "../../api";
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -160,7 +162,23 @@ function createTask(id: string, column: Task["column"], overrides: Partial<Task>
|
|||||||
} as Task;
|
} as Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsBoardHarness({ tasks, openTaskOnMountId }: { tasks: Task[]; openTaskOnMountId?: string }) {
|
function ThrowOnAutoMergeOff({ autoMerge }: { autoMerge: boolean }) {
|
||||||
|
if (autoMerge === false) {
|
||||||
|
throw new Error("Auto-merge render failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsBoardHarness({
|
||||||
|
tasks,
|
||||||
|
openTaskOnMountId,
|
||||||
|
includeThrowProbe = false,
|
||||||
|
}: {
|
||||||
|
tasks: Task[];
|
||||||
|
openTaskOnMountId?: string;
|
||||||
|
includeThrowProbe?: boolean;
|
||||||
|
}) {
|
||||||
const { autoMerge, toggleAutoMerge, maxConcurrent } = useAppSettings("proj_123");
|
const { autoMerge, toggleAutoMerge, maxConcurrent } = useAppSettings("proj_123");
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
const handleOpenDetail = (task: Task) => setSelectedTask(task);
|
const handleOpenDetail = (task: Task) => setSelectedTask(task);
|
||||||
@@ -197,11 +215,73 @@ function SettingsBoardHarness({ tasks, openTaskOnMountId }: { tasks: Task[]; ope
|
|||||||
<TaskReviewTab task={selectedTask} addToast={vi.fn()} autoMergeEnabled={autoMerge} prAuthAvailable />
|
<TaskReviewTab task={selectedTask} addToast={vi.fn()} autoMergeEnabled={autoMerge} prAuthAvailable />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{includeThrowProbe ? <ThrowOnAutoMergeOff autoMerge={autoMerge} /> : null}
|
||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
</RetryWarningProvider>
|
</RetryWarningProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppShellMobileHarness({ tasks }: { tasks: Task[] }) {
|
||||||
|
const viewportMode = useViewportMode();
|
||||||
|
const isMobile = viewportMode === "mobile";
|
||||||
|
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||||
|
const { autoMerge, toggleAutoMerge, maxConcurrent } = useAppSettings("proj_123");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RetryWarningProvider value={undefined}>
|
||||||
|
<div className={`project-content${isMobile && !keyboardOpen ? " project-content--with-mobile-nav" : ""}`}>
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<Board
|
||||||
|
tasks={tasks}
|
||||||
|
projectId="proj_123"
|
||||||
|
maxConcurrent={maxConcurrent}
|
||||||
|
onMoveTask={vi.fn(async () => ({} as Task))}
|
||||||
|
onOpenDetail={vi.fn()}
|
||||||
|
addToast={vi.fn()}
|
||||||
|
onQuickCreate={vi.fn(async () => undefined)}
|
||||||
|
onNewTask={vi.fn()}
|
||||||
|
autoMerge={autoMerge}
|
||||||
|
onToggleAutoMerge={toggleAutoMerge}
|
||||||
|
globalPaused={false}
|
||||||
|
prAuthAvailable={true}
|
||||||
|
/>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
</div>
|
||||||
|
{isMobile && !keyboardOpen ? (
|
||||||
|
<MobileNavBar
|
||||||
|
view="board"
|
||||||
|
onChangeView={vi.fn()}
|
||||||
|
footerVisible={true}
|
||||||
|
modalOpen={false}
|
||||||
|
keyboardOpen={keyboardOpen}
|
||||||
|
onOpenSettings={vi.fn()}
|
||||||
|
onOpenActivityLog={vi.fn()}
|
||||||
|
onOpenSystemStats={vi.fn()}
|
||||||
|
onOpenMailbox={vi.fn()}
|
||||||
|
onOpenGitManager={vi.fn()}
|
||||||
|
onOpenWorkflowEditor={vi.fn()}
|
||||||
|
onOpenSchedules={vi.fn()}
|
||||||
|
onOpenScripts={vi.fn()}
|
||||||
|
onToggleTerminal={vi.fn()}
|
||||||
|
onOpenFiles={vi.fn()}
|
||||||
|
onOpenTodos={vi.fn()}
|
||||||
|
onOpenGitHubImport={vi.fn()}
|
||||||
|
onOpenPlanning={vi.fn()}
|
||||||
|
onResumePlanning={vi.fn()}
|
||||||
|
onOpenUsage={vi.fn()}
|
||||||
|
onRunScript={vi.fn()}
|
||||||
|
onViewAllProjects={vi.fn()}
|
||||||
|
onOpenNodes={vi.fn()}
|
||||||
|
projectId="proj_123"
|
||||||
|
activePlanningSessionCount={0}
|
||||||
|
experimentalFeatures={{}}
|
||||||
|
pluginDashboardViews={[]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</RetryWarningProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function expectBoardVisible(taskTitles: string[] = []) {
|
function expectBoardVisible(taskTitles: string[] = []) {
|
||||||
expect(document.querySelector("main.board")).not.toBeNull();
|
expect(document.querySelector("main.board")).not.toBeNull();
|
||||||
expect(screen.getByText("In Review")).toBeInTheDocument();
|
expect(screen.getByText("In Review")).toBeInTheDocument();
|
||||||
@@ -222,18 +302,53 @@ function createInReviewAndWorktreeTasks() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installMobileDeviceEnvironment() {
|
||||||
|
Object.defineProperty(window.navigator, "maxTouchPoints", {
|
||||||
|
configurable: true,
|
||||||
|
value: 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAppShellHarness({
|
||||||
|
width,
|
||||||
|
height = 812,
|
||||||
|
tasks,
|
||||||
|
autoMerge = false,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height?: number;
|
||||||
|
tasks: Task[];
|
||||||
|
autoMerge?: Settings["autoMerge"];
|
||||||
|
}) {
|
||||||
|
const viewportSpy = mockViewport(width, height);
|
||||||
|
const visualViewport = createVisualViewport();
|
||||||
|
Object.defineProperty(window, "visualViewport", {
|
||||||
|
configurable: true,
|
||||||
|
value: visualViewport,
|
||||||
|
});
|
||||||
|
installMobileDeviceEnvironment();
|
||||||
|
installAnimationFrame();
|
||||||
|
mockSettings = { ...defaultSettings, autoMerge };
|
||||||
|
|
||||||
|
render(<AppShellMobileHarness tasks={tasks} />);
|
||||||
|
|
||||||
|
return { viewportSpy, visualViewport };
|
||||||
|
}
|
||||||
|
|
||||||
function renderBoardHarness({
|
function renderBoardHarness({
|
||||||
width,
|
width,
|
||||||
height = 812,
|
height = 812,
|
||||||
tasks,
|
tasks,
|
||||||
autoMerge = false,
|
autoMerge = false,
|
||||||
openTaskOnMountId,
|
openTaskOnMountId,
|
||||||
|
includeThrowProbe = false,
|
||||||
}: {
|
}: {
|
||||||
width: number;
|
width: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
autoMerge?: Settings["autoMerge"];
|
autoMerge?: Settings["autoMerge"];
|
||||||
openTaskOnMountId?: string;
|
openTaskOnMountId?: string;
|
||||||
|
includeThrowProbe?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const viewportSpy = mockViewport(width, height);
|
const viewportSpy = mockViewport(width, height);
|
||||||
const visualViewport = createVisualViewport();
|
const visualViewport = createVisualViewport();
|
||||||
@@ -244,7 +359,13 @@ function renderBoardHarness({
|
|||||||
installAnimationFrame();
|
installAnimationFrame();
|
||||||
mockSettings = { ...defaultSettings, autoMerge };
|
mockSettings = { ...defaultSettings, autoMerge };
|
||||||
|
|
||||||
render(<SettingsBoardHarness tasks={tasks} openTaskOnMountId={openTaskOnMountId} />);
|
render(
|
||||||
|
<SettingsBoardHarness
|
||||||
|
tasks={tasks}
|
||||||
|
openTaskOnMountId={openTaskOnMountId}
|
||||||
|
includeThrowProbe={includeThrowProbe}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
return { viewportSpy, visualViewport };
|
return { viewportSpy, visualViewport };
|
||||||
}
|
}
|
||||||
@@ -253,10 +374,12 @@ describe("auto-merge toggle mobile integration regression", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
_resetInitialViewportHeight();
|
||||||
mockSettings = { ...defaultSettings };
|
mockSettings = { ...defaultSettings };
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
_resetInitialViewportHeight();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -365,6 +488,97 @@ describe("auto-merge toggle mobile integration regression", () => {
|
|||||||
viewportSpy.mockRestore();
|
viewportSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the page error boundary fallback instead of a blank page when a sibling render throws after auto-merge toggles off", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const { viewportSpy, visualViewport } = renderBoardHarness({
|
||||||
|
width: 375,
|
||||||
|
tasks: createInReviewAndWorktreeTasks(),
|
||||||
|
autoMerge: true,
|
||||||
|
includeThrowProbe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expectBoardVisible(["FN-5972", "Worktree child task"]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
visualViewport.dispatchResize();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||||
|
expect(document.querySelector("main.board")).toBeNull();
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the App-level mobile shell visible while round-tripping auto-merge", async () => {
|
||||||
|
const { viewportSpy, visualViewport } = renderAppShellHarness({
|
||||||
|
width: 375,
|
||||||
|
tasks: createInReviewAndWorktreeTasks(),
|
||||||
|
autoMerge: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
|
||||||
|
expect(toggle).not.toBeChecked();
|
||||||
|
expectBoardVisible(["FN-5972", "Worktree child task"]);
|
||||||
|
expect(document.querySelector(".project-content.project-content--with-mobile-nav")).not.toBeNull();
|
||||||
|
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
visualViewport.dispatchResize();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toggle).toBeChecked();
|
||||||
|
expectBoardVisible(["FN-5972", "Worktree child task"]);
|
||||||
|
expect(document.querySelector(".project-content.project-content--with-mobile-nav")).not.toBeNull();
|
||||||
|
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
visualViewport.dispatchResize();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(toggle).not.toBeChecked();
|
||||||
|
expectBoardVisible(["FN-5972", "Worktree child task"]);
|
||||||
|
expect(document.querySelector(".project-content.project-content--with-mobile-nav")).not.toBeNull();
|
||||||
|
expect(screen.getByTestId("mobile-nav-tab-tasks")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||||
|
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ name: "mobile landscape", width: 844, height: 390 },
|
{ name: "mobile landscape", width: 844, height: 390 },
|
||||||
{ name: "tablet", width: 834, height: 1112 },
|
{ name: "tablet", width: 834, height: 1112 },
|
||||||
|
|||||||
Reference in New Issue
Block a user