FN-5972: fix mobile auto-merge blank dashboard
Restore mobile auto-merge dashboard stability and broaden regression coverage. - share the mobile media query between viewport detection and board stabilization so landscape phones stay on the mobile path - add an integration regression suite for toggling auto-merge across mobile, tablet, desktop, rollback, and task review surfaces - expand dashboard and CLI tests, keep the new published changeset, and preserve related vitest coverage lists and safety comments Files changed: $(git diff --cached --stat) Fusion-Task-Id: FN-5972 Fusion-Task-Lineage: f3a3bbae-21c2-451b-b24f-848cc12d0542
This commit is contained in:
@@ -349,6 +349,25 @@ describe("project commands", () => {
|
||||
await expect(runProjectAdd("bad name", "/tmp")).rejects.toThrow("process.exit:1");
|
||||
});
|
||||
|
||||
it("prompts for a missing project name in interactive add mode", async () => {
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockRegisterProject.mockResolvedValue({
|
||||
id: "proj-1",
|
||||
name: "demo",
|
||||
path: ".",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
mockQuestion.mockResolvedValueOnce("demo");
|
||||
|
||||
const { runProjectAdd } = await import("../project.js");
|
||||
await expect(runProjectAdd("", ".", { force: true })).resolves.toBeUndefined();
|
||||
|
||||
expect(mockQuestion).toHaveBeenCalledWith(expect.stringContaining("Project name"));
|
||||
expect(mockRegisterProject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "demo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("validation exits on missing required args for runProjectRemove", async () => {
|
||||
const { runProjectRemove } = await import("../project.js");
|
||||
await expect(runProjectRemove("")).rejects.toThrow("process.exit:1");
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
|
||||
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
@@ -199,7 +200,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
// `overflow-anchor: none`) and only stabilize via reflow + scroll offset
|
||||
// normalization; do NOT reintroduce `scroll-snap-type: x mandatory`.
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia("(max-width: 768px)").matches) {
|
||||
if (!window.matchMedia(MOBILE_MEDIA_QUERY).matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { AgentsView } from "../AgentsView";
|
||||
import * as apiModule from "../../api";
|
||||
|
||||
const mockViewportMode = vi.fn<() => "mobile" | "tablet" | "desktop">(() => "desktop");
|
||||
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => mockViewportMode() }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockViewportMode() }));
|
||||
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
|
||||
vi.mock("../AgentDetailView", () => ({ AgentDetailView: () => null, relativeTime: () => "now" }));
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ vi.mock("../AgentDetailView", () => ({
|
||||
const mockViewportMode = vi.fn<() => "mobile" | "tablet" | "desktop">(() => "desktop");
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -582,6 +582,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
// depending on window.matchMedia in jsdom.
|
||||
const mockUseViewportMode = vi.fn(() => "desktop");
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
|
||||
getViewportMode: () => "desktop",
|
||||
}));
|
||||
@@ -4188,6 +4189,9 @@ describe("FN-5817 mobile auto-merge toggle stability", () => {
|
||||
|
||||
const toggle = await screen.findByRole("checkbox", { name: "Auto-merge" });
|
||||
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
|
||||
expect(document.querySelector("main.board")).not.toBeNull();
|
||||
expect(screen.getByText("In review task")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
@@ -4198,8 +4202,11 @@ describe("FN-5817 mobile auto-merge toggle stability", () => {
|
||||
);
|
||||
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: "Auto-merge" })).toBeInTheDocument();
|
||||
expect(document.querySelector("main.board")).not.toBeNull();
|
||||
expect(screen.getByText("In review task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -45,7 +45,9 @@ function mockViewport(mode: "mobile" | "desktop") {
|
||||
configurable: true,
|
||||
});
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: mode === "mobile" && query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
|
||||
matches:
|
||||
mode === "mobile" &&
|
||||
(query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
|
||||
@@ -276,7 +276,9 @@ function mockViewportMode(mode: "mobile" | "desktop") {
|
||||
const isMobile = mode === "mobile";
|
||||
Object.defineProperty(window, "innerWidth", { value: isMobile ? 375 : 1280, configurable: true });
|
||||
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: isMobile && query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
|
||||
matches:
|
||||
isMobile &&
|
||||
(query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
|
||||
@@ -19,6 +19,7 @@ const mockUseMobileKeyboard = vi.fn(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("../../api", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
|
||||
@@ -17,6 +17,7 @@ const mockFetchMissionInterviewDrafts = vi.fn();
|
||||
const mockSubscribeSse = vi.fn(() => vi.fn());
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ vi.mock("../../hooks/useConfirm", () => ({ useConfirm: vi.fn(() => ({ confirm: v
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn(() => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false })) }));
|
||||
vi.mock("../../hooks/useMobileScrollLock", () => ({ useMobileScrollLock: vi.fn() }));
|
||||
vi.mock("../../hooks/useNodes", () => ({ useNodes: vi.fn(() => ({ nodes: [] })) }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn(() => "desktop") }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: vi.fn(() => "desktop") }));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const mockFetchModels = vi.fn();
|
||||
const mockSubscribeSse = vi.fn(() => vi.fn());
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: vi.fn(() => "desktop"),
|
||||
}));
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ vi.mock("../../hooks/useQuickChat", () => ({
|
||||
}));
|
||||
vi.mock("../../hooks/useFileMention", () => ({ useFileMention: vi.fn(() => ({ mentionActive: false, detectMention: vi.fn(), dismissMention: vi.fn(), handleKeyDown: vi.fn(), selectTask: vi.fn(), selectFile: vi.fn(), tasks: [], files: [], combinedItems: [], loading: false, mentionQuery: "", selectedIndex: 0, setSelectedIndex: vi.fn() })) }));
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn(() => ({ keyboardOpen: false, keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0 })) }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn(() => "desktop") }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: vi.fn(() => "desktop") }));
|
||||
vi.mock("react-markdown", () => ({ default: ({ children }: { children: string }) => children }));
|
||||
|
||||
function deferred<T>() {
|
||||
|
||||
@@ -28,7 +28,8 @@ vi.mock("../../api", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgents", () => ({ useAgents: vi.fn() }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn() }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: vi.fn() }));
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn() }));
|
||||
vi.mock("../../hooks/useAppSettings", () => ({ useAppSettings: vi.fn() }));
|
||||
vi.mock("../../hooks/useChatRooms", () => ({ useChatRooms: vi.fn() }));
|
||||
|
||||
@@ -47,7 +47,7 @@ function escapeRegExp(value: string): string {
|
||||
|
||||
function extractMobileMediaBlocks(content: string): string {
|
||||
const blocks: string[] = [];
|
||||
const queryPattern = `${escapeRegExp(MOBILE_MEDIA_QUERY)}(?:\\s*,\\s*\\([^)]*\\))*`;
|
||||
const queryPattern = `(?:${escapeRegExp(MOBILE_MEDIA_QUERY)}|\\(max-width:\\s*768px\\))(?:\\s*,\\s*\\([^)]*\\))*`;
|
||||
const regex = new RegExp(`@media\\s*${queryPattern}\\s*\\{`, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ vi.mock("../../hooks/useConfirm", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
|
||||
@@ -17,7 +17,8 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
|
||||
useMemoryBackendStatus: () => ({ status: null, capabilities: null, loading: false, error: null, refresh: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => "desktop" }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => "desktop" }));
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }),
|
||||
}));
|
||||
|
||||
@@ -31,7 +31,8 @@ vi.mock("../../hooks/useWorktrunkInstallStatus", () => ({
|
||||
useWorktrunkInstallStatus: (...args: unknown[]) => mockUseWorktrunkInstallStatus(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => "desktop" }));
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => "desktop" }));
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: () => ({ keyboardOpen: false, keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0 }),
|
||||
}));
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
|
||||
|
||||
@@ -506,18 +506,20 @@ describe("TaskReviewTab", () => {
|
||||
expect(mobileMediaStart).toBeGreaterThanOrEqual(0);
|
||||
const mobileCss = taskReviewCss.slice(mobileMediaStart);
|
||||
const baseSummaryWrapRule = taskReviewCss.match(/\.task-review-tab__summary-wrap\s*\{[^}]*\}/)?.[0] ?? "";
|
||||
const baseBodyRule = taskReviewCss.match(/\.task-review-tab__body\s*\{[^}]*\}/)?.[0] ?? "";
|
||||
|
||||
expect(baseSummaryWrapRule).toMatch(/flex\s*:\s*1\s+1\s+20rem\s*;/);
|
||||
expect(baseSummaryWrapRule).not.toMatch(/flex\s*:\s*0\s+0\s+auto\s*;/);
|
||||
expect(mobileCss).toMatch(/\.task-review-tab__header\s*\{[^}]*flex-direction\s*:\s*column\s*;[^}]*\}/);
|
||||
expect(mobileCss).toMatch(/\.task-review-tab__summary-wrap\s*\{[^}]*flex\s*:\s*0\s+0\s+auto\s*;[^}]*\}/);
|
||||
expect(mobileCss).not.toMatch(/\.task-review-tab__summary-wrap\s*\{[^}]*flex\s*:\s*1\s+1\s+20rem\s*;[^}]*\}/);
|
||||
expect(mobileCss).toMatch(/\.task-review-tab__summary-wrap,\s*\.task-review-tab__actions,\s*\.task-review-tab__auto-merge-control\s*\{[^}]*width\s*:\s*100%\s*;[^}]*\}/);
|
||||
expect(mobileCss).toMatch(/\.task-review-tab__actions\s*\{[^}]*justify-content\s*:\s*flex-start\s*;[^}]*\}/);
|
||||
expect(mobileCss).toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*width\s*:\s*100%\s*;[^}]*\}/);
|
||||
expect(mobileCss).toMatch(/\.task-review-tab__body\s*\{[^}]*padding\s*:\s*var\(--space-sm\)\s*;[^}]*\}/);
|
||||
expect(mobileCss).not.toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*flex\s*:\s*1\s*;[^}]*\}/);
|
||||
|
||||
expect(taskReviewCss).toMatch(/\.task-review-tab__body\s*\{[^}]*overflow-wrap\s*:\s*anywhere\s*;[^}]*overflow-x\s*:\s*auto\s*;[^}]*\}/);
|
||||
expect(baseBodyRule).toMatch(/overflow-wrap\s*:\s*anywhere\s*;/);
|
||||
expect(baseBodyRule).toMatch(/overflow-x\s*:\s*auto\s*;/);
|
||||
expect(taskReviewCss).toMatch(/\.task-review-tab__item\s*\{[^}]*padding\s*:\s*var\(--card-padding\)\s*;[^}]*\}/);
|
||||
});
|
||||
|
||||
@@ -772,6 +774,47 @@ describe("TaskReviewTab", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent("Effective: Auto-merge off — frozen on entry to review"));
|
||||
});
|
||||
|
||||
it("keeps the review tab visible when global auto-merge changes while task detail is open", async () => {
|
||||
const inReviewTask = makeTask({ column: "in-review", autoMerge: undefined, reviewState: { source: "pull-request", items: [], addressing: [] } });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: inReviewTask.reviewState, automationStatus: null, emptyMessage: null });
|
||||
|
||||
const { rerender } = render(
|
||||
<TaskReviewTab
|
||||
task={inReviewTask}
|
||||
addToast={vi.fn()}
|
||||
prAuthAvailable
|
||||
onRequestCreatePr={vi.fn()}
|
||||
autoMergeEnabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-review-auto-merge-select")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-review-create-pr")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent(
|
||||
"Effective: Auto-merge off — frozen on entry to review",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<TaskReviewTab
|
||||
task={inReviewTask}
|
||||
addToast={vi.fn()}
|
||||
prAuthAvailable
|
||||
onRequestCreatePr={vi.fn()}
|
||||
autoMergeEnabled
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-review-auto-merge-select")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
|
||||
expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent(
|
||||
"Effective: Auto-merge on — frozen on entry to review",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("reflects current per-task auto-merge selection", async () => {
|
||||
const task = makeTask({ autoMerge: true, reviewState: { source: "pull-request", items: [], addressing: [] } });
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import type { Settings, Task } from "@fusion/core";
|
||||
import { Board } from "../Board";
|
||||
import { PageErrorBoundary } from "../ErrorBoundary";
|
||||
import { TaskReviewTab } from "../TaskReviewTab";
|
||||
import { RetryWarningProvider } from "../../context/RetryWarningContext";
|
||||
import { useAppSettings } from "../../hooks/useAppSettings";
|
||||
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
|
||||
import { fetchConfig, fetchSettings, fetchTaskReview, updateSettings } from "../../api";
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
recycleWorktrees: false,
|
||||
worktreeInitCommand: "",
|
||||
testCommand: "",
|
||||
buildCommand: "",
|
||||
capacityRiskBannerEnabled: false,
|
||||
capacityRiskTodoThreshold: 20,
|
||||
experimentalFeatures: {},
|
||||
};
|
||||
|
||||
let mockSettings: Settings = { ...defaultSettings };
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const { createDashboardApiMock } = await import("../../test/mockApi");
|
||||
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
|
||||
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })),
|
||||
fetchSettings: vi.fn(() => Promise.resolve({ ...mockSettings })),
|
||||
updateSettings: vi.fn((updates: Partial<Settings>) => {
|
||||
mockSettings = { ...mockSettings, ...updates };
|
||||
return Promise.resolve({ ...mockSettings });
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn(() => Promise.resolve([])),
|
||||
fetchAgents: vi.fn(() => Promise.resolve([])),
|
||||
fetchTaskReview: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
reviewState: { source: "pull-request", items: [], addressing: [] },
|
||||
automationStatus: null,
|
||||
emptyMessage: null,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("../../hooks/useBlockerFanout", () => ({
|
||||
useBlockerFanout: () => new Map(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useFlashOnIncrease", () => ({
|
||||
useFlashOnIncrease: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useBadgeWebSocket", () => ({
|
||||
useBadgeWebSocket: () => ({
|
||||
badgeUpdates: new Map(),
|
||||
subscribeToBadge: vi.fn(),
|
||||
unsubscribeFromBadge: vi.fn(),
|
||||
isConnected: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTaskDiffStats", () => ({
|
||||
useTaskDiffStats: () => ({ stats: null, loading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgentsMapCache", () => ({
|
||||
useAgentsMapCache: () => ({ agentsMap: new Map(), agents: [], loading: false, refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../PluginSlot", () => ({
|
||||
PluginSlot: () => null,
|
||||
}));
|
||||
|
||||
function ensureMatchMedia() {
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mockViewport(width: number, height = 812) {
|
||||
ensureMatchMedia();
|
||||
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
|
||||
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches:
|
||||
query === MOBILE_MEDIA_QUERY
|
||||
? width <= 768 || height <= 480
|
||||
: query === "(min-width: 769px) and (max-width: 1024px)"
|
||||
? width >= 769 && width <= 1024
|
||||
: 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,
|
||||
offsetTop: 0,
|
||||
height: 812,
|
||||
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 installAnimationFrame() {
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
setTimeout(() => cb(0), 0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
}
|
||||
|
||||
function createTask(id: string, column: Task["column"], overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
description: `${id} description`,
|
||||
column,
|
||||
status: column === "in-review" ? "in-review" : overrides.status,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function SettingsBoardHarness({ tasks, openTaskOnMountId }: { tasks: Task[]; openTaskOnMountId?: string }) {
|
||||
const { autoMerge, toggleAutoMerge, maxConcurrent } = useAppSettings("proj_123");
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const handleOpenDetail = (task: Task) => setSelectedTask(task);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openTaskOnMountId) {
|
||||
return;
|
||||
}
|
||||
const initialTask = tasks.find((task) => task.id === openTaskOnMountId) ?? null;
|
||||
if (initialTask) {
|
||||
handleOpenDetail(initialTask);
|
||||
}
|
||||
}, [openTaskOnMountId, tasks]);
|
||||
|
||||
return (
|
||||
<RetryWarningProvider value={undefined}>
|
||||
<PageErrorBoundary>
|
||||
<Board
|
||||
tasks={tasks}
|
||||
projectId="proj_123"
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={vi.fn(async () => ({} as Task))}
|
||||
onOpenDetail={handleOpenDetail}
|
||||
addToast={vi.fn()}
|
||||
onQuickCreate={vi.fn(async () => undefined)}
|
||||
onNewTask={vi.fn()}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={toggleAutoMerge}
|
||||
globalPaused={false}
|
||||
prAuthAvailable={true}
|
||||
/>
|
||||
{selectedTask ? (
|
||||
<div data-testid="task-detail-review-surface">
|
||||
<TaskReviewTab task={selectedTask} addToast={vi.fn()} autoMergeEnabled={autoMerge} prAuthAvailable />
|
||||
</div>
|
||||
) : null}
|
||||
</PageErrorBoundary>
|
||||
</RetryWarningProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function expectBoardVisible(taskTitles: string[] = []) {
|
||||
expect(document.querySelector("main.board")).not.toBeNull();
|
||||
expect(screen.getByText("In Review")).toBeInTheDocument();
|
||||
for (const title of taskTitles) {
|
||||
expect(screen.getAllByText(title).length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
}
|
||||
|
||||
function createInReviewAndWorktreeTasks() {
|
||||
return [
|
||||
createTask("FN-5972", "in-review"),
|
||||
createTask("FN-5972-WT", "in-progress", {
|
||||
title: "Worktree child task",
|
||||
status: "in-progress",
|
||||
worktree: "/workspace/project/.worktrees/FN-5972-WT",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function renderBoardHarness({
|
||||
width,
|
||||
height = 812,
|
||||
tasks,
|
||||
autoMerge = false,
|
||||
openTaskOnMountId,
|
||||
}: {
|
||||
width: number;
|
||||
height?: number;
|
||||
tasks: Task[];
|
||||
autoMerge?: Settings["autoMerge"];
|
||||
openTaskOnMountId?: string;
|
||||
}) {
|
||||
const viewportSpy = mockViewport(width, height);
|
||||
const visualViewport = createVisualViewport();
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: visualViewport,
|
||||
});
|
||||
installAnimationFrame();
|
||||
mockSettings = { ...defaultSettings, autoMerge };
|
||||
|
||||
render(<SettingsBoardHarness tasks={tasks} openTaskOnMountId={openTaskOnMountId} />);
|
||||
|
||||
return { viewportSpy, visualViewport };
|
||||
}
|
||||
|
||||
describe("auto-merge toggle mobile integration regression", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockSettings = { ...defaultSettings };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the real board/task-card and worktree-group composition visible on mobile portrait after toggling auto-merge on and back off", async () => {
|
||||
const { viewportSpy, visualViewport } = renderBoardHarness({
|
||||
width: 375,
|
||||
tasks: createInReviewAndWorktreeTasks(),
|
||||
autoMerge: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetchConfig).toHaveBeenCalledWith("proj_123");
|
||||
expect(fetchSettings).toHaveBeenCalledWith("proj_123");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expectBoardVisible(["FN-5972", "Worktree child task"]);
|
||||
expect(screen.getAllByText("FN-5972-WT").length).toBeGreaterThan(0);
|
||||
|
||||
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(screen.getByRole("button", { name: /create pull request/i })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
|
||||
expect(toggle).toBeChecked();
|
||||
expect(screen.queryByRole("button", { name: /create pull request/i })).toBeNull();
|
||||
expectBoardVisible(["FN-5972", "Worktree child task"]);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(updateSettings).toHaveBeenLastCalledWith({ autoMerge: false }, "proj_123");
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(screen.getByRole("button", { name: /create pull request/i })).toBeInTheDocument();
|
||||
expectBoardVisible(["FN-5972", "Worktree child task"]);
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps the board visible for an empty in-review column while round-tripping auto-merge on mobile", async () => {
|
||||
const { viewportSpy, visualViewport } = renderBoardHarness({
|
||||
width: 375,
|
||||
tasks: [],
|
||||
autoMerge: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
|
||||
expect(toggle).toBeChecked();
|
||||
expectBoardVisible();
|
||||
expect(screen.getAllByText("No tasks").length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(toggle).not.toBeChecked();
|
||||
expectBoardVisible();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(toggle).toBeChecked();
|
||||
expectBoardVisible();
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "mobile landscape", width: 844, height: 390 },
|
||||
{ name: "tablet", width: 834, height: 1112 },
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
])("keeps the board visible after toggling auto-merge on $name", async ({ width, height }) => {
|
||||
const { viewportSpy, visualViewport } = renderBoardHarness({
|
||||
width,
|
||||
height,
|
||||
tasks: [createTask(`FN-${width}`, "in-review")],
|
||||
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-${width}`]);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(toggle).toBeChecked();
|
||||
expectBoardVisible([`FN-${width}`]);
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps task review detail visible while toggling auto-merge with a detail panel open", async () => {
|
||||
const detailTask = createTask("FN-DETAIL", "in-review", {
|
||||
reviewState: { source: "pull-request", items: [], addressing: [] },
|
||||
});
|
||||
vi.mocked(fetchTaskReview).mockResolvedValue({
|
||||
reviewState: detailTask.reviewState ?? { source: "pull-request", items: [], addressing: [] },
|
||||
automationStatus: null,
|
||||
emptyMessage: null,
|
||||
} as never);
|
||||
|
||||
const { viewportSpy, visualViewport } = renderBoardHarness({
|
||||
width: 375,
|
||||
tasks: [detailTask],
|
||||
autoMerge: false,
|
||||
openTaskOnMountId: detailTask.id,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("task-detail-review-surface")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent(
|
||||
"Effective: Auto-merge off",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("task-detail-review-surface")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent(
|
||||
"Effective: Auto-merge on",
|
||||
);
|
||||
expectBoardVisible(["FN-DETAIL"]);
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("rolls back the real useAppSettings toggle on mobile without blanking the board when updateSettings fails", async () => {
|
||||
vi.mocked(updateSettings).mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
const { viewportSpy, visualViewport } = renderBoardHarness({
|
||||
width: 375,
|
||||
tasks: [createTask("FN-ROLLBACK", "in-review")],
|
||||
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-ROLLBACK"]);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(toggle);
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
visualViewport.dispatchResize();
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
|
||||
expect(toggle).not.toBeChecked();
|
||||
expect(screen.getByRole("button", { name: /create pull request/i })).toBeInTheDocument();
|
||||
expectBoardVisible(["FN-ROLLBACK"]);
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ 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 { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -54,11 +55,12 @@ function ensureMatchMedia() {
|
||||
}
|
||||
}
|
||||
|
||||
function mockViewport(width: number) {
|
||||
function mockViewport(width: number, height = 812) {
|
||||
ensureMatchMedia();
|
||||
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
|
||||
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: query === "(max-width: 768px)" ? width <= 768 : false,
|
||||
matches: query === MOBILE_MEDIA_QUERY ? width <= 768 || height <= 480 : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
|
||||
@@ -288,6 +288,7 @@ vi.mock("../../hooks/useNodes", () => ({
|
||||
|
||||
const mockUseViewportMode = vi.fn(() => "desktop");
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
useViewportMode: (..._args: unknown[]) => mockUseViewportMode(..._args),
|
||||
getViewportMode: () => "desktop",
|
||||
}));
|
||||
|
||||
@@ -389,7 +389,7 @@ describe("useChatRooms", () => {
|
||||
expect(mockPostChatRoomMessage).not.toHaveBeenCalledWith("room-1", expect.objectContaining({ content: "hello" }), "proj-1");
|
||||
});
|
||||
|
||||
it("rejects with original error when post fails and recovery transcript has no persisted user message", async () => {
|
||||
it("rejects with original error while preserving recovered replies when post fails without a persisted user message", async () => {
|
||||
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
|
||||
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
|
||||
const { result } = renderHook(() => useChatRooms("proj-1"));
|
||||
@@ -400,9 +400,14 @@ describe("useChatRooms", () => {
|
||||
act(() => result.current.selectRoom("room-1"));
|
||||
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
|
||||
|
||||
const recoveredAssistantReply = {
|
||||
...roomMessage("msg-assistant", "room-1", "Room reply"),
|
||||
role: "assistant" as const,
|
||||
senderAgentId: "agent-1",
|
||||
};
|
||||
mockPostChatRoomMessage.mockRejectedValueOnce(new Error("POST failed"));
|
||||
mockFetchChatRoomMessages.mockResolvedValueOnce({
|
||||
messages: [{ ...roomMessage("msg-assistant", "room-1", "Room reply"), role: "assistant", senderAgentId: "agent-1" }],
|
||||
messages: [recoveredAssistantReply],
|
||||
});
|
||||
|
||||
let postError: unknown;
|
||||
@@ -418,7 +423,7 @@ describe("useChatRooms", () => {
|
||||
expect((postError as Error).message).toBe("POST failed");
|
||||
expect(postError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
|
||||
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.messages).toEqual([recoveredAssistantReply]);
|
||||
});
|
||||
|
||||
it("uses desc order when refreshing after send failure", async () => {
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useViewportMode } from "../useViewportMode";
|
||||
import { getViewportMode, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
|
||||
|
||||
describe("useViewportMode", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("treats short landscape phones as mobile", () => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn((query: string) => ({
|
||||
matches:
|
||||
query === MOBILE_MEDIA_QUERY
|
||||
? true
|
||||
: query === "(min-width: 769px) and (max-width: 1024px)"
|
||||
? false
|
||||
: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
expect(getViewportMode()).toBe("mobile");
|
||||
expect(renderHook(() => useViewportMode()).result.current).toBe("mobile");
|
||||
});
|
||||
|
||||
it("supports legacy MediaQueryList listeners without runtime errors", () => {
|
||||
const listeners: Array<() => void> = [];
|
||||
const removeListener = vi.fn((listener: () => void) => {
|
||||
@@ -18,7 +39,7 @@ describe("useViewportMode", () => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn((query: string) => ({
|
||||
matches: query === "(max-width: 768px)",
|
||||
matches: query === MOBILE_MEDIA_QUERY,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: (listener: () => void) => listeners.push(listener),
|
||||
|
||||
@@ -6,7 +6,7 @@ export type ViewportMode = "mobile" | "tablet" | "desktop";
|
||||
// 768 CSS px wide but stay short. Without it, landscape phones fall out of
|
||||
// mobile mode and lose the bottom nav bar + get the desktop horizontally-
|
||||
// scrollable board.
|
||||
export const MOBILE_MEDIA_QUERY = "(max-width: 768px)";
|
||||
export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
|
||||
|
||||
export function getViewportMode(): ViewportMode {
|
||||
if (typeof window === "undefined") return "desktop";
|
||||
|
||||
@@ -92,7 +92,7 @@ const qualityAppFoundationUiTests = [
|
||||
const qualityAppHooksAndUtilsTests = [
|
||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||
"app/context/**/*.test.tsx",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms,useTerminalSessions,useTheme,useToast,useUsageData,useViewportMode,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}",
|
||||
"app/utils/**/*.test.{ts,tsx}",
|
||||
];
|
||||
|
||||
@@ -108,6 +108,8 @@ const qualityAppComponentTests = [
|
||||
"AuthTokenRecoveryDialog",
|
||||
"Board",
|
||||
"Board.canDropTask",
|
||||
"auto-merge-toggle-blank.mobile",
|
||||
"auto-merge-toggle-blank.mobile-integration",
|
||||
"board-mobile",
|
||||
"board-mobile-view-switch",
|
||||
"BranchGroupCard",
|
||||
|
||||
Reference in New Issue
Block a user