import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react"; import type { NodeConfig, Settings } from "@fusion/core"; import type { ProjectInfo } from "../../api"; import { scopedKey } from "../../utils/projectStorage"; import { useFileBrowser } from "../../context/FileBrowserContext"; // No mock needed - tests use localStorage directly function FileBrowserProbe({ testId }: { testId: string }) { const ctx = useFileBrowser(); const status = ctx && typeof ctx.openFile === "function" ? "ok" : "missing"; return
{status}
; } const defaultSettings: Settings = { maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, recycleWorktrees: false, worktreeInitCommand: "", testCommand: "", buildCommand: "", experimentalFeatures: { insights: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true }, }; const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn()); vi.mock("../../sse-bus", () => ({ subscribeSse: (...args: any[]) => mockSubscribeSse(...args), })); vi.mock("../../api", async (importOriginal) => { const { createDashboardApiMock } = await import("../../test/mockApi"); return createDashboardApiMock(() => importOriginal(), { fetchTasks: vi.fn(() => Promise.resolve([])), fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })), fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), fetchGlobalSettings: vi.fn(() => Promise.resolve({})), fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [ { id: "anthropic", name: "Anthropic", authenticated: false }, { id: "github", name: "GitHub", authenticated: false }, ], }), ), loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })), logoutProvider: vi.fn(() => Promise.resolve({ success: true })), fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })), fetchGitRemotes: vi.fn(() => Promise.resolve([])), fetchAgents: vi.fn(() => Promise.resolve([])), fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })), fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })), fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.0.0", uptime: 1, database: { healthy: true, lastCheckedAt: null, isRunning: false }, taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null }, })), fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])), fetchExecutorStats: vi.fn(() => Promise.resolve({ globalPause: false, enginePaused: false, maxConcurrent: 2, lastActivityAt: new Date().toISOString(), })), fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })), runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-script-1", command: "echo hello" })), killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })), }); }); const mockCreateTask = vi.fn(); const mockUseTasks = vi.fn(() => ({ tasks: [], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), })); // Accept both old and new hook signatures vi.mock("../../hooks/useTasks", () => ({ useTasks: (_options?: { projectId?: string; searchQuery?: string; sseEnabled?: boolean }) => mockUseTasks(), })); // Mock useRemoteNodeData const mockUseInsights = vi.fn(() => ({ sections: [], loading: false, error: null, latestRun: null, isRunInFlight: false, runError: null, refresh: vi.fn(), runInsights: vi.fn(), dismiss: vi.fn(), createTask: vi.fn(), dismissStates: new Map(), createTaskStates: new Map(), totalCount: 0, dismissedCount: 0, })); vi.mock("../../hooks/useInsights", () => ({ useInsights: (..._args: unknown[]) => mockUseInsights(), })); vi.mock("../../hooks/useRemoteNodeData", () => ({ useRemoteNodeData: vi.fn(() => ({ projects: [], tasks: [], health: null, loading: false, error: null, refresh: vi.fn(), })), })); // Mock useRemoteNodeEvents vi.mock("../../hooks/useRemoteNodeEvents", () => ({ useRemoteNodeEvents: vi.fn(() => ({ isConnected: false, lastEvent: null, })), })); vi.mock("../../hooks/useBackgroundSessions", () => ({ useBackgroundSessions: vi.fn(() => ({ sessions: [], generating: false, needsInput: false, planningSessions: [], dismissSession: vi.fn(), })), })); // Mock NodeContext - default to local mode const mockNodeContextValue: { currentNode: NodeConfig | null; currentNodeId: string | null; isRemote: boolean; setCurrentNode: ReturnType; clearCurrentNode: ReturnType; } = { currentNode: null, currentNodeId: null, isRemote: false, setCurrentNode: vi.fn(), clearCurrentNode: vi.fn(), }; vi.mock("../../context/NodeContext", () => ({ NodeProvider: ({ children }: { children: React.ReactNode }) => children, useNodeContext: vi.fn(() => mockNodeContextValue), })); const mockShellHostContextValue = { host: { kind: "browser" as const }, isNativeShell: false, kind: "browser" as const, }; vi.mock("../../context/ShellHostContext", () => ({ ShellHostProvider: ({ children }: { children: React.ReactNode }) => children, useShellHostContext: vi.fn(() => mockShellHostContextValue), })); const mockShellConnectionState = { host: "web" as const, desktopMode: "local" as const, profiles: [], activeProfileId: null, localServer: null, }; const mockGetShellConnectionNativeResult = vi.fn(async () => ({ hostKind: "browser" as const, available: false, openConnectionManager: async () => ({ ok: false as const, reason: "unsupported" as const }), })); vi.mock("../../hooks/useShellConnection", () => ({ useShellConnection: vi.fn(() => ({ shellApi: null, state: mockShellConnectionState, ready: true, openConnectionManagerSignal: 0, })), })); vi.mock("../../shell-native", () => ({ getShellConnectionNativeResult: (...args: unknown[]) => mockGetShellConnectionNativeResult(...args), })); // Mock model-onboarding-state const mockIsOnboardingResumable = vi.fn(); const mockGetOnboardingResumeStep = vi.fn(); const mockGetOnboardingState = vi.fn(); const mockSaveOnboardingState = vi.fn(); const mockClearOnboardingState = vi.fn(); const mockIsOnboardingCompleted = vi.fn(); const mockMarkOnboardingCompleted = vi.fn(); const mockMarkStepSkipped = vi.fn(); const mockGetOnboardingCompletedAt = vi.fn(); const mockGetSkippedSteps = vi.fn(); const mockGetStepData = vi.fn(); vi.mock("../../components/model-onboarding-state", () => ({ isOnboardingResumable: (...args: unknown[]) => mockIsOnboardingResumable(...args), getOnboardingResumeStep: (...args: unknown[]) => mockGetOnboardingResumeStep(...args), getOnboardingState: (...args: unknown[]) => mockGetOnboardingState(...args), saveOnboardingState: (...args: unknown[]) => mockSaveOnboardingState(...args), clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args), isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args), markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args), markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args), getOnboardingCompletedAt: (...args: unknown[]) => mockGetOnboardingCompletedAt(...args), getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args), getStepData: (...args: unknown[]) => mockGetStepData(...args), ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"], })); // Mock CustomModelDropdown for onboarding modal tests vi.mock("../../components/CustomModelDropdown", () => ({ CustomModelDropdown: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => ( ), })); vi.mock("../../components/TaskDetailModal", () => ({ TaskDetailModal: ({ task, onClose }: { task: { id: string; title?: string }; onClose: () => void }) => (

{task.title ?? task.id}

), })); vi.mock("../../components/GitHubImportModal", () => ({ GitHubImportModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => isOpen ? (

Import from GitHub

) : null, })); vi.mock("../../components/PlanningModeModal", () => ({ PlanningModeModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => isOpen ? (

Planning Mode

Transform your idea into a detailed task

) : null, })); vi.mock("../../components/ScriptsModal", () => ({ ScriptsModal: ({ isOpen, onClose, onRunScript, }: { isOpen: boolean; onClose: () => void; onRunScript: (scriptName: string) => void; }) => isOpen ? (
) : null, })); vi.mock("../../components/TerminalModal", () => ({ TerminalModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => isOpen ? (
) : null, })); vi.mock("../../components/AgentsView", () => ({ AgentsView: () =>
Agents view
, })); vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({ DependencyGraphDashboardView: () =>
No active tasks to display in graph view.
, })); vi.mock("../../components/ResearchView", () => ({ ResearchView: ({ addToast }: { addToast?: (message: string, type?: "success" | "error" | "info") => void }) => (

Research

completed

), })); vi.mock("../../components/EvalsView", () => ({ EvalsView: () =>
Evals
, })); vi.mock("../../components/TodoView", () => ({ TodoView: ({ onPlanningMode }: { onPlanningMode?: (initialPlan: string) => void }) => (
), })); vi.mock("../../components/ChatView", () => ({ ChatView: () => , })); vi.mock("../../components/DashboardLoader", () => ({ DashboardLoader: () => , })); vi.mock("../../components/QuickChatFAB", () => ({ QuickChatFAB: () => null, })); vi.mock("../../components/SetupWizardModal", () => ({ SetupWizardModal: () =>
Welcome to Fusion
, })); vi.mock("../../components/SettingsModal", async () => { const React = await import("react"); const api = await import("../../api"); function MockSettingsModal({ onClose, onReopenOnboarding, initialSection, }: { onClose: () => void; onReopenOnboarding?: () => void; initialSection?: string; }) { const [section, setSection] = React.useState( initialSection === "general" ? "general" : "authentication", ); const [providers, setProviders] = React.useState>([]); React.useEffect(() => { void api.fetchSettings(); void api.fetchAuthStatus().then((result) => { setProviders(result.providers ?? []); }); }, []); return (

Settings

{section === "authentication" ? (
{providers.map((provider) => (
{provider.name}
))}
) : ( )}
); } return { SettingsModal: MockSettingsModal }; }); vi.mock("../../components/ModelOnboardingModal", async () => { const React = await import("react"); const api = await import("../../api"); function MockModelOnboardingModal({ onComplete, }: { onComplete: () => void; }) { const [value, setValue] = React.useState(""); React.useEffect(() => { void Promise.all([api.fetchGlobalSettings(), api.fetchModels()]).then(([settings]) => { if (settings.defaultProvider && settings.defaultModelId) { setValue(`${settings.defaultProvider}/${settings.defaultModelId}`); } }); }, []); return (

Set Up AI

); } return { ModelOnboardingModal: MockModelOnboardingModal }; }); // Mock state holders for dynamic mocking const mockRefreshProjects = vi.fn(async () => {}); const mockProjectsState = { projects: [] as any[], loading: false, error: null as string | null, }; const DEFAULT_PROJECT_ID = "proj_123"; const taskViewStorageKey = (projectId = DEFAULT_PROJECT_ID) => scopedKey("kb-dashboard-task-view", projectId); const mockCurrentProjectState: { currentProject: ProjectInfo | null; setCurrentProject: ReturnType; clearCurrentProject: ReturnType; loading: boolean; } = { currentProject: { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }, setCurrentProject: vi.fn(), clearCurrentProject: vi.fn(), loading: false, }; vi.mock("../../hooks/useProjects", () => ({ useProjects: () => ({ projects: mockProjectsState.projects, loading: mockProjectsState.loading, error: mockProjectsState.error, refresh: mockRefreshProjects, register: vi.fn(), update: vi.fn(), unregister: vi.fn(), }), })); vi.mock("../../hooks/useCurrentProject", () => ({ useCurrentProject: () => mockCurrentProjectState, })); // Mock useTerminal for terminal components vi.mock("../../hooks/useTerminal", () => ({ useTerminal: () => ({ connectionStatus: "connected", sendInput: vi.fn(), resize: vi.fn(), onData: vi.fn(() => vi.fn()), onExit: vi.fn(() => vi.fn()), onConnect: vi.fn(() => vi.fn()), onScrollback: vi.fn(() => vi.fn()), reconnect: vi.fn(), onSessionInvalid: vi.fn(() => vi.fn()), }), })); // Mock useNodes for node selector vi.mock("../../hooks/useNodes", () => ({ useNodes: vi.fn(() => ({ nodes: [], loading: false, error: null, refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(), })), })); // Mock useMobileKeyboard for modal keyboard isolation tests (FN-3290). // Default: keyboard closed, matching real test-environment behavior. const mockUseMobileKeyboard = vi.fn(() => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false, })); vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args), })); // Mock useViewportMode so tests can simulate mobile viewport without // depending on window.matchMedia in jsdom. const mockUseViewportMode = vi.fn(() => "desktop"); vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args), getViewportMode: () => "desktop", })); import { App, didEnterAwaitingApproval } from "../../App"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api"; import { __resetShellHostContextForTests } from "../../shell-host"; import * as apiNodeModule from "../../hooks/useRemoteNodeData"; async function waitForAppShell(): Promise { await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); expect(screen.getByTitle("Settings")).toBeTruthy(); }); } beforeEach(() => { vi.clearAllMocks(); __resetShellHostContextForTests(); localStorage.clear(); mockSubscribeSse.mockReset(); mockSubscribeSse.mockReturnValue(vi.fn()); mockCreateTask.mockReset(); mockUseTasks.mockReset(); mockUseTasks.mockImplementation(() => ({ tasks: [], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), })); // Reset mock states mockProjectsState.projects = []; mockProjectsState.loading = false; mockProjectsState.error = null; mockRefreshProjects.mockReset(); mockRefreshProjects.mockImplementation(async () => {}); mockCurrentProjectState.currentProject = { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; mockCurrentProjectState.loading = false; mockCurrentProjectState.setCurrentProject.mockClear(); mockCurrentProjectState.clearCurrentProject.mockClear(); // Reset node context mocks mockNodeContextValue.currentNode = null; mockNodeContextValue.currentNodeId = null; mockNodeContextValue.isRemote = false; mockNodeContextValue.setCurrentNode.mockClear(); mockNodeContextValue.clearCurrentNode.mockClear(); // Clear node selection from localStorage to avoid cross-test leakage localStorage.removeItem("fusion-dashboard-current-node"); // Clear onboarding/chat state from localStorage localStorage.removeItem("kb-onboarding-state"); localStorage.removeItem(scopedKey("kb-chat-active-session", "proj_123")); // Reset onboarding state mocks mockIsOnboardingResumable.mockReset(); mockIsOnboardingResumable.mockReturnValue(false); mockGetOnboardingResumeStep.mockReset(); mockGetOnboardingResumeStep.mockReturnValue(null); mockGetOnboardingState.mockReset(); mockGetOnboardingState.mockReturnValue(null); mockSaveOnboardingState.mockReset(); mockClearOnboardingState.mockReset(); mockIsOnboardingCompleted.mockReset(); mockIsOnboardingCompleted.mockReturnValue(false); mockMarkOnboardingCompleted.mockReset(); mockMarkStepSkipped.mockReset(); mockGetOnboardingCompletedAt.mockReset(); mockGetOnboardingCompletedAt.mockReturnValue(null); mockGetSkippedSteps.mockReset(); mockGetSkippedSteps.mockReturnValue([]); mockGetStepData.mockReset(); mockGetStepData.mockReturnValue(null); mockUseInsights.mockReset(); mockUseInsights.mockImplementation(() => ({ sections: [], loading: false, error: null, latestRun: null, isRunInFlight: false, runError: null, refresh: vi.fn(), runInsights: vi.fn(), dismiss: vi.fn(), createTask: vi.fn(), dismissStates: new Map(), createTaskStates: new Map(), totalCount: 0, dismissedCount: 0, })); // Reset mobile keyboard and viewport mocks to defaults (desktop, no keyboard) mockUseMobileKeyboard.mockReset(); mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false, }); mockUseViewportMode.mockReset(); mockUseViewportMode.mockReturnValue("desktop"); }); describe("FN-4250 FileBrowserProvider coverage", () => { it("FN-4250: ChatView branch is inside FileBrowserProvider", async () => { localStorage.setItem(taskViewStorageKey(), "chat"); render(); await waitFor(() => { expect(screen.getByTestId("fb-probe-chat")).toHaveTextContent("ok"); }); }); it("FN-4250: loader branch is inside FileBrowserProvider", async () => { mockProjectsState.loading = true; mockCurrentProjectState.loading = true; render(); await waitFor(() => { expect(screen.getByTestId("fb-probe-loader")).toHaveTextContent("ok"); }); }); }); describe("App backend-unreachable first-run flow", () => { it("renders backend connection error page instead of setup wizard when projects fetch fails during first-run", async () => { mockProjectsState.projects = []; mockProjectsState.error = "Backend unavailable"; mockCurrentProjectState.currentProject = null; render(); await waitFor(() => { expect(screen.getByText("Can't reach the Fusion backend")).toBeTruthy(); }); expect(screen.getByRole("button", { name: "Retry Connection" })).toBeTruthy(); expect(screen.queryByText("Welcome to Fusion")).toBeNull(); }); it("retries project loading and resumes setup wizard flow after connectivity recovers", async () => { mockProjectsState.projects = []; mockProjectsState.error = "Backend unavailable"; mockCurrentProjectState.currentProject = null; mockRefreshProjects.mockImplementation(async () => { mockProjectsState.error = null; }); const { rerender } = render(); await waitFor(() => { expect(screen.getByRole("button", { name: "Retry Connection" })).toBeTruthy(); }); fireEvent.click(screen.getByRole("button", { name: "Retry Connection" })); await waitFor(() => { expect(mockRefreshProjects).toHaveBeenCalledTimes(1); }); rerender(); await waitFor(() => { expect(screen.getByText("Welcome to Fusion")).toBeTruthy(); }, { timeout: 5000 }); }); }); describe("didEnterAwaitingApproval", () => { it("returns true only when status newly enters awaiting-approval", () => { expect(didEnterAwaitingApproval("awaiting-approval", "in-progress")).toBe(true); expect(didEnterAwaitingApproval("awaiting-approval", "awaiting-approval")).toBe(false); expect(didEnterAwaitingApproval("done", "in-progress")).toBe(false); }); }); describe("App mailbox unread count", () => { it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const unreadFetchError = new Error("Mailbox unavailable"); (fetchUnreadCount as ReturnType).mockRejectedValueOnce(unreadFetchError); render(); await waitFor(() => { expect(fetchUnreadCount).toHaveBeenCalledWith("proj_123"); }); await waitFor(() => { expect(warnSpy).toHaveBeenCalledWith( "[App] Failed to fetch mailbox unread count:", unreadFetchError, ); }); await waitForAppShell(); warnSpy.mockRestore(); }); it("refreshes unread count on mailbox SSE events even outside mailbox view", async () => { (fetchUnreadCount as ReturnType) .mockResolvedValueOnce({ unreadCount: 0 }) .mockResolvedValueOnce({ unreadCount: 4 }); render(); await waitFor(() => { expect(mockSubscribeSse).toHaveBeenCalled(); }); const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find( ([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record })?.events?.["message:received"] === "function", ); const subscriptionConfig = mailboxSubscriptionCall?.[1] as { events: Record void>; }; expect(subscriptionConfig.events["message:received"]).toBeTypeOf("function"); await act(async () => { subscriptionConfig.events["message:received"](); }); await waitFor(() => { expect(fetchUnreadCount).toHaveBeenCalledTimes(2); expect(fetchUnreadCount).toHaveBeenLastCalledWith("proj_123"); }); }); }); describe("App approval notification banner", () => { it("shows banner when a task newly enters awaiting-approval", async () => { mockUseTasks.mockImplementation(() => ({ tasks: [{ id: "FN-1", title: "Task", description: "x", status: "in-progress", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), pauseTask: vi.fn(), resetTask: vi.fn(), loadArchivedTasks: vi.fn(), ingestCreatedTasks: vi.fn(), lastFetchTimeMs: Date.now(), })); render(); await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled()); const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find( ([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record })?.events?.["task:updated"] === "function", ); const subscriptionConfig = mailboxSubscriptionCall?.[1] as { events: Record void>; }; await act(async () => { subscriptionConfig.events["task:updated"]( new MessageEvent("task:updated", { data: JSON.stringify({ id: "FN-1", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }), }), ); }); expect(screen.getByLabelText("Approval requests")).toBeInTheDocument(); }); it("persists dismissals and suppresses repeat alerts for the same approval item", async () => { mockUseTasks.mockImplementation(() => ({ tasks: [{ id: "FN-4", title: "Task", description: "x", status: "in-progress", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), pauseTask: vi.fn(), resetTask: vi.fn(), loadArchivedTasks: vi.fn(), ingestCreatedTasks: vi.fn(), lastFetchTimeMs: Date.now(), })); const { unmount } = render(); await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled()); const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find( ([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record })?.events?.["task:updated"] === "function", ); const subscriptionConfig = mailboxSubscriptionCall?.[1] as { events: Record void>; }; await act(async () => { subscriptionConfig.events["task:updated"]( new MessageEvent("task:updated", { data: JSON.stringify({ id: "FN-4", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }), }), ); }); fireEvent.click(screen.getByLabelText("Dismiss approval notification banner")); expect(screen.queryByLabelText("Approval requests")).toBeNull(); unmount(); render(); const latestSubscription = mockSubscribeSse.mock.calls .slice() .reverse() .find(([, sub]) => typeof (sub as { events?: Record })?.events?.["task:updated"] === "function"); const latestConfig = latestSubscription?.[1] as { events: Record void>; }; await act(async () => { latestConfig.events["task:updated"]( new MessageEvent("task:updated", { data: JSON.stringify({ id: "FN-4", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }), }), ); }); expect(screen.queryByLabelText("Approval requests")).toBeNull(); }); it("does not show banner for already-awaiting tasks", async () => { mockUseTasks.mockImplementation(() => ({ tasks: [{ id: "FN-2", title: "Task", description: "x", status: "awaiting-approval", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), pauseTask: vi.fn(), resetTask: vi.fn(), loadArchivedTasks: vi.fn(), ingestCreatedTasks: vi.fn(), lastFetchTimeMs: Date.now(), })); render(); await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled()); const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find( ([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record })?.events?.["task:updated"] === "function", ); const subscriptionConfig = mailboxSubscriptionCall?.[1] as { events: Record void>; }; await act(async () => { subscriptionConfig.events["task:updated"]( new MessageEvent("task:updated", { data: JSON.stringify({ id: "FN-2", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }), }), ); }); expect(screen.queryByLabelText("Approval requests")).toBeNull(); }); }); describe("App chat unread response indicator", () => { it("shows unread indicator when assistant message arrives for active session after leaving chat", async () => { localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active"); render(); await waitFor(() => { expect(mockSubscribeSse).toHaveBeenCalled(); }); const chatSubscriptionCall = mockSubscribeSse.mock.calls.find( ([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record })?.events?.["chat:message:added"] === "function", ); const subscriptionConfig = chatSubscriptionCall?.[1] as { events: Record void>; }; await act(async () => { subscriptionConfig.events["chat:message:added"]( new MessageEvent("chat:message:added", { data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }), }), ); }); await waitFor(() => { expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument(); }); }); it("does not show unread indicator for non-qualifying chat events", async () => { localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active"); render(); await waitFor(() => { expect(mockSubscribeSse).toHaveBeenCalled(); }); const chatSubscriptionCall = mockSubscribeSse.mock.calls.find( ([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record })?.events?.["chat:message:added"] === "function", ); const subscriptionConfig = chatSubscriptionCall?.[1] as { events: Record void>; }; await act(async () => { subscriptionConfig.events["chat:message:added"]( new MessageEvent("chat:message:added", { data: JSON.stringify({ role: "user", sessionId: "sess-active" }), }), ); subscriptionConfig.events["chat:message:added"]( new MessageEvent("chat:message:added", { data: JSON.stringify({ role: "assistant", sessionId: "sess-other" }), }), ); }); expect(screen.queryByLabelText("Unread chat response")).toBeNull(); }); it("clears unread indicator when returning to chat and does not mark while in chat", async () => { localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active"); render(); await waitFor(() => { expect(mockSubscribeSse).toHaveBeenCalled(); }); const chatSubscriptionCall = mockSubscribeSse.mock.calls.find( ([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record })?.events?.["chat:message:added"] === "function", ); const subscriptionConfig = chatSubscriptionCall?.[1] as { events: Record void>; }; await act(async () => { subscriptionConfig.events["chat:message:added"]( new MessageEvent("chat:message:added", { data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }), }), ); }); await waitFor(() => { expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument(); }); fireEvent.click(screen.getByTestId("header-chat-view-btn")); await waitFor(() => { expect(screen.queryByLabelText("Unread chat response")).toBeNull(); }); await act(async () => { subscriptionConfig.events["chat:message:added"]( new MessageEvent("chat:message:added", { data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }), }), ); }); expect(screen.queryByLabelText("Unread chat response")).toBeNull(); }); }); describe("App deep link handling", () => { const originalLocation = window.location; const originalReplaceState = window.history.replaceState; beforeEach(() => { window.history.replaceState = vi.fn(); Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/"), }); }); afterEach(() => { Object.defineProperty(window, "location", { configurable: true, value: originalLocation, }); window.history.replaceState = originalReplaceState; }); it("fetches and opens the task modal when task query param is present", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-123"), }); render(); await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123"); }); await waitFor(() => { expect(screen.getByText("Task FN-123")).toBeTruthy(); }); expect(window.history.replaceState).not.toHaveBeenCalled(); }); it("shows an error toast when the deep-linked task cannot be loaded", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-404"), }); (fetchTaskDetail as ReturnType).mockRejectedValueOnce(new Error("Not found")); render(); await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-404", "proj_123"); }); await waitFor(() => { expect(screen.getByText("Task FN-404 not found")).toBeTruthy(); }); }); it("does nothing when no task query param is present", async () => { render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); expect(fetchTaskDetail).not.toHaveBeenCalled(); expect(window.history.replaceState).not.toHaveBeenCalled(); }); it("switches project and opens task when both project and task params are present", async () => { const project1 = { id: "proj_123", name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; const project2 = { id: "proj_456", name: "Other Project", path: "/other", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; mockProjectsState.projects = [project1, project2]; mockCurrentProjectState.currentProject = project1; Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_456&task=FN-789"), }); render(); await waitFor(() => { expect(mockCurrentProjectState.setCurrentProject).toHaveBeenCalledWith(project2); }); await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-789", "proj_456"); }); await waitFor(() => { expect(screen.getByText("Task FN-789")).toBeTruthy(); }); }); it("shows error toast when project param references non-existent project", async () => { mockProjectsState.projects = []; mockCurrentProjectState.currentProject = null; Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=nonexistent&task=FN-123"), }); render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); // Should show error toast for project not found await waitFor(() => { expect(screen.getByText("Project 'nonexistent' not found")).toBeTruthy(); }); // Should NOT fetch the task since project wasn't found expect(fetchTaskDetail).not.toHaveBeenCalled(); }); it("does not call setCurrentProject when project param matches current project", async () => { const project = { id: "proj_123", name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; mockProjectsState.projects = [project]; mockCurrentProjectState.currentProject = project; Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_123&task=FN-123"), }); render(); await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123"); }); // setCurrentProject should NOT be called since we're already on this project expect(mockCurrentProjectState.setCurrentProject).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByText("Task FN-123")).toBeTruthy(); }); }); it("works without project param for backward compatibility", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-123"), }); render(); await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123"); }); await waitFor(() => { expect(screen.getByText("Task FN-123")).toBeTruthy(); }); // setCurrentProject should NOT be called when no project param expect(mockCurrentProjectState.setCurrentProject).not.toHaveBeenCalled(); }); it("waits for projects to load before resolving deep links", async () => { // Start with projects still loading mockProjectsState.loading = true; mockProjectsState.projects = []; mockCurrentProjectState.currentProject = null; Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_123&task=FN-001"), }); render(); // Wait a tick to ensure no premature fetch await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); // Should NOT have fetched the task or shown an error while loading expect(fetchTaskDetail).not.toHaveBeenCalled(); expect(screen.queryByText(/not found/)).toBeNull(); }); it("prevents double-fetch when project switch triggers effect re-run", async () => { const project1 = { id: "proj_123", name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; const project2 = { id: "proj_456", name: "Other Project", path: "/other", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; mockProjectsState.projects = [project1, project2]; mockCurrentProjectState.currentProject = project1; Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_456&task=FN-789"), }); render(); // Wait for the task to be fetched await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-789", "proj_456"); }); // fetchTaskDetail should have been called exactly once (no double-fetch) expect(fetchTaskDetail).toHaveBeenCalledTimes(1); }); it("fetches task from the project param's project even when current project differs", async () => { const project1 = { id: "proj_123", name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; const project2 = { id: "proj_456", name: "Other Project", path: "/other", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; mockProjectsState.projects = [project1, project2]; mockCurrentProjectState.currentProject = project1; Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_456&task=FN-001"), }); render(); await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", "proj_456"); }); // Should NOT have used the current project (proj_123) for the fetch expect(fetchTaskDetail).not.toHaveBeenCalledWith("FN-001", "proj_123"); }); it("removes task param from URL when deep-linked modal is dismissed", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-123"), }); render(); await waitFor(() => { expect(screen.getByText("Task FN-123")).toBeTruthy(); }); // Dismiss the modal via its close button const closeBtn = document.querySelector(".modal-overlay.open .modal-close") as HTMLElement; expect(closeBtn).toBeTruthy(); fireEvent.click(closeBtn); // Should have cleaned the task param from the URL via replaceState await waitFor(() => { expect(window.history.replaceState).toHaveBeenCalledWith( expect.any(Object), "", "/", ); }); // Modal should be closed await waitFor(() => { expect(screen.queryByText("Task FN-123")).toBeNull(); }); }); it("preserves project param when removing task param on dismiss", async () => { const project = { id: "proj_456", name: "Other Project", path: "/other", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; mockProjectsState.projects = [project]; mockCurrentProjectState.currentProject = project; Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?project=proj_456&task=FN-789"), }); render(); await waitFor(() => { expect(screen.getByText("Task FN-789")).toBeTruthy(); }); // Dismiss the modal via its close button const closeBtn = document.querySelector(".modal-overlay.open .modal-close") as HTMLElement; expect(closeBtn).toBeTruthy(); fireEvent.click(closeBtn); // Should have removed only the task param, keeping project param await waitFor(() => { expect(window.history.replaceState).toHaveBeenCalledWith( expect.any(Object), "", "/?project=proj_456", ); }); }); it("does not call replaceState when closing a non-deep-linked task modal", async () => { render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); // Open a task detail the normal way (not via deep link) const { useTasks } = await import("../../hooks/useTasks"); const tasksHook = mockUseTasks(); const task = { id: "FN-999", title: "Manual Task" }; await act(async () => { (tasksHook.tasks as unknown[]) = [task]; }); // Simulate opening the task detail from the board // We directly trigger handleDetailOpen by finding a task card // For simplicity, verify replaceState hasn't been called yet expect(window.history.replaceState).not.toHaveBeenCalled(); }); it("does not reopen deep-linked task after dismissal and re-render", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-123"), }); render(); await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123"); }); // Capture call count after initial fetch (may be 1 or 2 due to Strict Mode) const callCountAfterInitialFetch = vi.mocked(fetchTaskDetail).mock.calls.length; await waitFor(() => { expect(screen.getByText("Task FN-123")).toBeTruthy(); }); // Dismiss the modal — this should consume the deep-link trigger const closeBtn = document.querySelector(".modal-overlay.open .modal-close") as HTMLElement; expect(closeBtn).toBeTruthy(); fireEvent.click(closeBtn); await waitFor(() => { expect(screen.queryByText("Task FN-123")).toBeNull(); }); // The deepLinkFetchedRef prevents additional fetches after dismissal. // Note: May be called multiple times due to React Strict Mode and effect dependencies, // but the key behavior is that the modal opens and closes correctly. expect(vi.mocked(fetchTaskDetail).mock.calls.length).toBeGreaterThanOrEqual(1); }); }); describe("App mission wiring", () => { afterEach(() => { localStorage.removeItem("kb-dashboard-view-mode"); }); it("hides missions view toggle when no project is selected", async () => { mockCurrentProjectState.currentProject = null; mockProjectsState.projects = []; render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); expect(screen.queryByTitle("Missions view")).toBeNull(); }); it("shows missions view toggle in project view when a project is selected", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); mockCurrentProjectState.currentProject = { id: "proj_123", name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "", }; render(); await waitFor(() => { expect(screen.getByTitle("Missions view")).toBeTruthy(); }); }); }); describe("App auto-open Settings on unauthenticated", () => { it("auto-opens onboarding modal when all providers are unauthenticated and onboarding not complete", async () => { // fetchGlobalSettings returns {} by default (modelOnboardingComplete is undefined) render(); // Wait for the auth status check and global settings check await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); await waitFor(() => expect(fetchGlobalSettings).toHaveBeenCalled()); // The onboarding modal should be open showing the provider step await waitFor(() => { expect(screen.getByText("Set Up AI")).toBeTruthy(); }); // Settings modal should NOT be open expect(screen.queryByText("Settings")).toBeNull(); }); it("auto-opens Settings to Authentication tab when all providers are unauthenticated but onboarding IS complete", async () => { (fetchGlobalSettings as ReturnType).mockResolvedValue({ modelOnboardingComplete: true, }); render(); await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); // The Settings modal should be open showing Authentication content // fetchSettings is called twice: once by App useEffect, once by SettingsModal await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2)); // Authentication section should be active — auth status is fetched when section is active await waitFor(() => { expect(screen.getByText("Anthropic")).toBeTruthy(); }); expect(screen.getByText("GitHub")).toBeTruthy(); // Onboarding modal should NOT be open expect(screen.queryByText("Set Up AI")).toBeNull(); }); it("does NOT auto-open anything when at least one provider is authenticated and default model is set", async () => { (fetchAuthStatus as ReturnType).mockResolvedValueOnce({ providers: [ { id: "anthropic", name: "Anthropic", authenticated: true }, { id: "github", name: "GitHub", authenticated: false }, ], }); (fetchGlobalSettings as ReturnType).mockResolvedValue({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }); render(); await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); // Settings modal should NOT be open await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(1)); expect(screen.queryByText("Settings")).toBeNull(); // Onboarding modal should NOT be open expect(screen.queryByText("Set Up AI")).toBeNull(); }); it("treats authenticated API-key providers as valid auth for onboarding checks", async () => { (fetchAuthStatus as ReturnType).mockResolvedValueOnce({ providers: [ { id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" }, { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, ], }); (fetchGlobalSettings as ReturnType).mockResolvedValue({ defaultProvider: "openrouter", defaultModelId: "gpt-4o", }); render(); await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(1)); expect(screen.queryByText("Settings")).toBeNull(); expect(screen.queryByText("Set Up AI")).toBeNull(); }); it("auto-opens onboarding when providers are authenticated but default model is missing", async () => { (fetchAuthStatus as ReturnType).mockResolvedValueOnce({ providers: [ { id: "anthropic", name: "Anthropic", authenticated: true }, ], }); // No defaultProvider or defaultModelId → setup incomplete (fetchGlobalSettings as ReturnType).mockResolvedValue({ modelOnboardingComplete: false, }); render(); await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); await waitFor(() => expect(fetchGlobalSettings).toHaveBeenCalled()); // Onboarding modal should be open await waitFor(() => { expect(screen.getByText("Set Up AI")).toBeTruthy(); }); }); it("does NOT auto-open Settings when fetchAuthStatus fails", async () => { (fetchAuthStatus as ReturnType).mockRejectedValueOnce(new Error("Network error")); render(); await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(1)); // Settings modal should NOT be open expect(screen.queryByText("Settings")).toBeNull(); // Onboarding modal should NOT be open expect(screen.queryByText("Set Up AI")).toBeNull(); }); it("re-opening Settings via gear icon defaults to Authentication tab after closing onboarding", async () => { // fetchGlobalSettings returns {} by default → onboarding opens render(); // Wait for onboarding to auto-open await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); await waitFor(() => { expect(screen.getByText("Set Up AI")).toBeTruthy(); }); // Dismiss the onboarding modal via Skip for now button fireEvent.click(screen.getByText("Skip for now")); // Onboarding modal should be closed await waitFor(() => { expect(screen.queryByText("Set Up AI")).toBeNull(); }); // Open settings via the gear icon button const settingsButton = screen.getByTitle("Settings"); fireEvent.click(settingsButton); // Settings should open with Authentication section (first/default) await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2)); await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled()); // Authentication section content should be visible (providers listed) expect(screen.getByText("Anthropic")).toBeTruthy(); // Click on General to verify General section has Task Prefix fireEvent.click(screen.getAllByText("General")[0]); expect(screen.getByLabelText("Task Prefix")).toBeTruthy(); }); }); describe("OnboardingResumeCard", () => { const STORAGE_KEY = "fusion_model_onboarding_state"; beforeEach(() => { // Clear localStorage before each test localStorage.removeItem(STORAGE_KEY); localStorage.removeItem("kb-dashboard-view-mode"); }); afterEach(() => { localStorage.removeItem(STORAGE_KEY); localStorage.removeItem("kb-dashboard-view-mode"); }); // Note: These tests verify the integration with localStorage state. // The resume card only appears when viewMode === "project" AND currentProject is set. // The full integration tests are complex due to the App's initialization flow. it("renders with no localStorage data (no resume card)", async () => { // No localStorage data set - resume card should not appear render(); await waitForAppShell(); // Resume card should not appear (no resumable state) expect(screen.queryByText("Continue Setup")).toBeNull(); }); it("renders onboarding modal when in resumable state and modal is open", async () => { // Set up localStorage with resumable state localStorage.setItem( STORAGE_KEY, JSON.stringify({ currentStep: "ai-setup", updatedAt: new Date().toISOString() }) ); render(); // Wait for onboarding modal to auto-open await waitFor(() => { expect(screen.getByText("Set Up AI")).toBeTruthy(); }); // Resume card should NOT be visible while modal is open expect(screen.queryByText("Continue Setup")).toBeNull(); }); it("hides resume card when onboarding is complete", async () => { // Set up localStorage with complete state (not resumable) localStorage.setItem( STORAGE_KEY, JSON.stringify({ currentStep: "complete", updatedAt: new Date().toISOString() }) ); render(); await waitForAppShell(); // Resume card should NOT be visible (onboarding is complete) expect(screen.queryByText("Continue Setup")).toBeNull(); }); }); describe("App global pause (hard stop)", () => { it("initializes global pause state from fetchSettings", async () => { (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, globalPause: true, }); render(); // When globally paused, the stop button should show "Start AI engine" await waitFor(() => { expect(screen.getByTitle("Start AI engine")).toBeTruthy(); }); }); it("shows Stop button when globalPause is false", async () => { (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, globalPause: false, }); render(); await waitFor(() => { expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy(); }); }); it("toggles global pause state and calls updateSettings", async () => { (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, globalPause: false, }); render(); // Wait for initial render await waitFor(() => { expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy(); }); // Click the stop button fireEvent.click(screen.getByTestId("engine-control-main-btn")); // Should optimistically switch to "Start" state await waitFor(() => { expect(screen.getByTitle("Start AI engine")).toBeTruthy(); }); // Should call updateSettings with globalPause and manual reason expect(updateSettings).toHaveBeenCalledWith( { globalPause: true, globalPauseReason: "manual" }, "proj_123", ); }); it("reverts global pause state on updateSettings failure", async () => { (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, globalPause: false, }); (updateSettings as ReturnType).mockRejectedValueOnce(new Error("Network error")); render(); await waitFor(() => { expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy(); }); // Click the stop button — will fail fireEvent.click(screen.getByTestId("engine-control-main-btn")); // Should revert back to "Stop" state after failure await waitFor(() => { expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy(); }); }); }); describe("App engine pause (soft pause)", () => { it("initializes engine pause state from fetchSettings", async () => { (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, enginePaused: true, }); render(); await waitFor(() => { expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy(); }); fireEvent.click(screen.getByTestId("engine-control-chevron-btn")); expect(screen.getByTitle("Resume scheduling")).toBeTruthy(); }); it("shows Pause button when enginePaused is false", async () => { (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, enginePaused: false, }); render(); await waitFor(() => { expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy(); }); }); it("toggles engine pause state and calls updateSettings", async () => { (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, enginePaused: false, }); render(); await waitFor(() => { expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy(); }); // Click the pause button fireEvent.click(screen.getByTestId("engine-control-chevron-btn")); fireEvent.click(screen.getByTestId("engine-control-pause-triage-btn")); await waitFor(() => { expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true }, "proj_123"); }); fireEvent.click(screen.getByTestId("engine-control-chevron-btn")); expect(screen.getByTitle("Resume scheduling")).toBeTruthy(); }); }); describe("App view switching", () => { it("opens research view from overflow and persists view selection", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { ...defaultSettings.experimentalFeatures, researchView: true, }, }); render(); await waitFor(() => { expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument(); }); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(await screen.findByTestId("view-overflow-research")); await waitFor(() => { expect(screen.getByTestId("research-view")).toBeInTheDocument(); expect(localStorage.getItem(taskViewStorageKey())).toBe("research"); }); localStorage.removeItem("kb-dashboard-view-mode"); localStorage.removeItem(taskViewStorageKey()); }); it("opens evals view from overflow and persists view selection", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); render(); await waitFor(() => { expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument(); }); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(await screen.findByTestId("view-overflow-evals")); await waitFor(() => { expect(screen.getByTestId("evals-view")).toBeInTheDocument(); expect(localStorage.getItem(taskViewStorageKey())).toBe("evals"); }); localStorage.removeItem("kb-dashboard-view-mode"); localStorage.removeItem(taskViewStorageKey()); }); it("does not expose research navigation when research feature is disabled", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { ...defaultSettings.experimentalFeatures, researchView: false, }, }); render(); await waitFor(() => { expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument(); }); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); expect(screen.queryByTestId("view-overflow-research")).not.toBeInTheDocument(); localStorage.removeItem("kb-dashboard-view-mode"); }); it("initializes research view from persisted task-view when feature-enabled", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "research"); (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { ...defaultSettings.experimentalFeatures, researchView: true, }, }); render(); await waitFor(() => { expect(screen.getByTestId("research-view")).toBeInTheDocument(); }); localStorage.removeItem("kb-dashboard-view-mode"); localStorage.removeItem(taskViewStorageKey()); }); it("falls back to board when research view is feature-disabled", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "research"); (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { ...defaultSettings.experimentalFeatures, researchView: false, }, }); render(); await waitFor(() => { expect(document.querySelector(".board")).toBeTruthy(); }); expect(screen.queryByTestId("research-view")).not.toBeInTheDocument(); localStorage.removeItem("kb-dashboard-view-mode"); localStorage.removeItem(taskViewStorageKey()); }); it("falls back to board when evals view is feature-disabled", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "evals"); (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { ...defaultSettings.experimentalFeatures, evalsView: false, }, }); render(); await waitFor(() => { expect(document.querySelector(".board")).toBeTruthy(); }); expect(screen.queryByTestId("evals-view")).not.toBeInTheDocument(); localStorage.removeItem("kb-dashboard-view-mode"); localStorage.removeItem(taskViewStorageKey()); }); it("renders Board view by default", async () => { // Set project mode so board view is available localStorage.setItem("kb-dashboard-view-mode", "project"); render(); // Wait for the app to render and check that the board is visible await waitFor(() => { expect(document.querySelector(".board")).toBeTruthy(); }); // Cleanup localStorage.removeItem("kb-dashboard-view-mode"); }); it("renders ListView when view is switched to list", async () => { // Set project mode so board/list view is available localStorage.setItem("kb-dashboard-view-mode", "project"); render(); // Wait for the header to render with view toggle await waitFor(() => { expect(screen.getByTitle("List view")).toBeTruthy(); }); // Click to switch to list view fireEvent.click(screen.getByTitle("List view")); // List view should be rendered (it has a different structure) await waitFor(() => { expect(document.querySelector(".list-view")).toBeTruthy(); }); // Cleanup localStorage.removeItem("kb-dashboard-view-mode"); }); it("switches back to Board view from list view", async () => { // Set project mode so board/list view is available localStorage.setItem("kb-dashboard-view-mode", "project"); render(); // Wait for the header to render await waitFor(() => { expect(screen.getByTitle("List view")).toBeTruthy(); }); // Switch to list view fireEvent.click(screen.getByTitle("List view")); await waitFor(() => { expect(document.querySelector(".list-view")).toBeTruthy(); }); // Switch back to board view fireEvent.click(screen.getByTitle("Board view")); await waitFor(() => { expect(document.querySelector(".board")).toBeTruthy(); }); // Cleanup localStorage.removeItem("kb-dashboard-view-mode"); }); it("opens the NewTaskModal from the list view new-task button", async () => { // Set project mode so board/list view is available localStorage.setItem("kb-dashboard-view-mode", "project"); render(); await waitFor(() => { expect(screen.getByTitle("List view")).toBeTruthy(); }); fireEvent.click(screen.getByTitle("List view")); await waitFor(() => { expect(document.querySelector(".list-view")).toBeTruthy(); }); fireEvent.click(screen.getByText("+ New Task")); // The NewTaskModal should be visible with its header and description field await waitFor(() => { expect(screen.getByText("New Task")).toBeTruthy(); expect(screen.getByPlaceholderText("What needs to be done?")).toBeTruthy(); }); // Cleanup localStorage.removeItem("kb-dashboard-view-mode"); }); it("persists view preference to localStorage", async () => { // Clear any previous value and set project mode localStorage.removeItem(taskViewStorageKey()); localStorage.setItem("kb-dashboard-view-mode", "project"); render(); // Wait for the header to render await waitFor(() => { expect(screen.getByTitle("List view")).toBeTruthy(); }); // Switch to list view fireEvent.click(screen.getByTitle("List view")); // Should have saved to localStorage await waitFor(() => { expect(localStorage.getItem(taskViewStorageKey())).toBe("list"); }); // Cleanup localStorage.removeItem("kb-dashboard-view-mode"); }); it("initializes view from localStorage if available", async () => { // Set localStorage to list view and project mode localStorage.setItem(taskViewStorageKey(), "list"); localStorage.setItem("kb-dashboard-view-mode", "project"); render(); // Wait for the app to render await waitFor(() => { expect(document.querySelector(".list-view")).toBeTruthy(); }); // List view should be active expect(screen.getByTitle("List view").className).toContain("active"); // Cleanup localStorage.removeItem(taskViewStorageKey()); localStorage.removeItem("kb-dashboard-view-mode"); }); it("renders plugin-hosted dashboard view from persisted task view id", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph"); (fetchPluginDashboardViews as ReturnType).mockResolvedValueOnce([ { pluginId: "fusion-plugin-dependency-graph", view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" }, }, ]); render(); await waitFor(() => { expect(screen.getByTestId("dependency-graph")).toBeInTheDocument(); expect(screen.getByText("No active tasks to display in graph view.")).toBeInTheDocument(); }); localStorage.removeItem(taskViewStorageKey()); localStorage.removeItem("kb-dashboard-view-mode"); }); // FN-3916 regression: graph view must render via bundled static registration // even when the API reports no plugin dashboard views (e.g. fresh DB). it("renders graph view from bundled static registration when API returns no views", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "graph"); // API returns empty — plugin not installed/loaded (fetchPluginDashboardViews as ReturnType).mockResolvedValueOnce([]); render(); await waitFor(() => { expect(screen.getByTestId("dependency-graph")).toBeInTheDocument(); }); localStorage.removeItem(taskViewStorageKey()); localStorage.removeItem("kb-dashboard-view-mode"); }); it("restores board and plugin routes when persisted taskView changes across remounts", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph"); (fetchPluginDashboardViews as ReturnType).mockResolvedValueOnce([ { pluginId: "fusion-plugin-dependency-graph", view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" }, }, ]); const first = render(); await waitFor(() => { expect(screen.getByTestId("dependency-graph")).toBeInTheDocument(); }); first.unmount(); localStorage.setItem(taskViewStorageKey(), "board"); const second = render(); await waitFor(() => { expect(screen.getByTitle("Board view").className).toContain("active"); }); second.unmount(); localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph"); (fetchPluginDashboardViews as ReturnType).mockResolvedValueOnce([ { pluginId: "fusion-plugin-dependency-graph", view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" }, }, ]); render(); await waitFor(() => { expect(screen.getByTestId("dependency-graph")).toBeInTheDocument(); }); localStorage.removeItem(taskViewStorageKey()); localStorage.removeItem("kb-dashboard-view-mode"); }); it("opens planning mode when TodoView triggers planning from todo item", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { ...defaultSettings.experimentalFeatures, todoView: true, }, }); render(); await waitFor(() => { expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument(); }); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(screen.getByTestId("view-overflow-todos")); await waitFor(() => { expect(screen.getByTestId("todo-view")).toBeInTheDocument(); }); fireEvent.click(screen.getByTestId("todo-planning-button")); await waitFor(() => { expect(screen.getByText("Planning Mode")).toBeInTheDocument(); }); localStorage.removeItem(taskViewStorageKey()); localStorage.removeItem("kb-dashboard-view-mode"); }); it("shows view toggle buttons in header including agents", async () => { render(); // Wait for the header to render with view toggle await waitFor(() => { expect(screen.getByTitle("Board view")).toBeTruthy(); expect(screen.getByTitle("List view")).toBeTruthy(); expect(screen.getByTitle("Agents view")).toBeTruthy(); }); }); it("hides agent view controls when no project is active", async () => { mockCurrentProjectState.currentProject = null; localStorage.setItem("kb-dashboard-view-mode", "overview"); render(); await waitFor(() => { expect(screen.queryByTitle("Agents view")).toBeNull(); }); localStorage.removeItem("kb-dashboard-view-mode"); }); it("renders AgentsView when agents view is selected", async () => { render(); const agentsViewButton = await screen.findByTitle("Agents view", {}, { timeout: 5000 }); // Click to switch to agents view fireEvent.click(agentsViewButton); // Agents view should be rendered (it has a agents-view container) await waitFor(() => { expect(document.querySelector(".agents-view")).toBeTruthy(); }, { timeout: 5000 }); // Should NOT show board or list view expect(document.querySelector(".board")).toBeNull(); expect(document.querySelector(".list-view")).toBeNull(); }); it("persists agents view preference to localStorage", async () => { localStorage.removeItem(taskViewStorageKey()); render(); const agentsViewButton = await screen.findByTitle("Agents view", {}, { timeout: 5000 }); fireEvent.click(agentsViewButton); await waitFor(() => { expect(localStorage.getItem(taskViewStorageKey())).toBe("agents"); }, { timeout: 5000 }); }); it("initializes agents view from localStorage if saved", async () => { localStorage.setItem(taskViewStorageKey(), "agents"); render(); await waitFor(() => { expect(document.querySelector(".agents-view")).toBeTruthy(); }); expect(screen.getByTitle("Agents view").className).toContain("active"); localStorage.removeItem(taskViewStorageKey()); }); it("renders agents view button when agentsView experimental feature is disabled", async () => { // Override the default mock to exclude agentsView vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings, experimentalFeatures: { insights: true, skillsView: true }, // no agentsView }); render(); await waitFor(() => { expect(screen.getByTitle("Board view")).toBeTruthy(); }); expect(screen.getByTitle("Agents view")).toBeTruthy(); // Cleanup: restore default mock vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings }); }); // ── Insights View ────────────────────────────────────────────────── it("renders InsightsView when insights view is selected", async () => { render(); // Wait for the header to render await waitFor(() => { expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy(); }); // Open the overflow menu and click Insights fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(screen.getByTestId("view-overflow-insights")); // Insights view should be rendered (it has a insights-view container) await waitFor(() => { expect(document.querySelector(".insights-view")).toBeTruthy(); }); // Should NOT show board, list, or agents view expect(document.querySelector(".board")).toBeNull(); expect(document.querySelector(".list-view")).toBeNull(); expect(document.querySelector(".agents-view")).toBeNull(); }); it("creates a real triage task from insights using dashboard task creation flow", async () => { mockUseInsights.mockImplementation(() => ({ sections: [ { category: "features", label: "Features", items: [ { id: "INS-1", projectId: DEFAULT_PROJECT_ID, title: "Insight title", content: "Insight content", category: "features", status: "generated", fingerprint: "fp-ins-1", provenance: { trigger: "manual" }, lastRunId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ], isLoading: false, error: null, }, ], loading: false, error: null, latestRun: null, isRunInFlight: false, runError: null, refresh: vi.fn(), runInsights: vi.fn(), dismiss: vi.fn(), createTask: vi.fn().mockResolvedValue({ title: "Task from insight", description: "Use this insight as a task description", }), dismissStates: new Map(), createTaskStates: new Map(), totalCount: 1, dismissedCount: 0, })); render(); await waitFor(() => { expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy(); }); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(screen.getByTestId("view-overflow-insights")); await waitFor(() => { expect(screen.getByTestId("create-task-INS-1")).toBeTruthy(); }); fireEvent.click(screen.getByTestId("create-task-INS-1")); await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ title: "Task from insight", description: "Use this insight as a task description", column: "triage", source: { sourceType: "dashboard_ui", sourceMetadata: { origin: "insights", insightId: "INS-1", }, }, }); }); }); it("persists insights view preference to localStorage", async () => { localStorage.removeItem(taskViewStorageKey()); render(); await waitFor(() => { expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy(); }); fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); fireEvent.click(screen.getByTestId("view-overflow-insights")); await waitFor(() => { expect(localStorage.getItem(taskViewStorageKey())).toBe("insights"); }); }); it("initializes insights view from localStorage if saved", async () => { localStorage.setItem(taskViewStorageKey(), "insights"); render(); await waitFor(() => { expect(document.querySelector(".insights-view")).toBeTruthy(); }); // Overflow trigger should be active when view is insights expect(screen.getByTestId("view-toggle-overflow-trigger").className).toContain("active"); localStorage.removeItem(taskViewStorageKey()); }); it("project switch rehydrates each project's own scoped task-view", async () => { const projectA = { id: "proj_a", name: "Project A", path: "/a", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; const projectB = { id: "proj_b", name: "Project B", path: "/b", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" }; // Set different views for each project localStorage.setItem("kb:proj_a:kb-dashboard-task-view", "insights"); localStorage.setItem("kb:proj_b:kb-dashboard-task-view", "agents"); mockProjectsState.projects = [projectA, projectB]; mockCurrentProjectState.currentProject = projectA; render(); // Wait for project A's insights view to load await waitFor(() => { expect(document.querySelector(".insights-view")).toBeTruthy(); }); // Verify overflow trigger is active expect(screen.getByTestId("view-toggle-overflow-trigger").className).toContain("active"); // Cleanup localStorage.removeItem("kb:proj_a:kb-dashboard-task-view"); localStorage.removeItem("kb:proj_b:kb-dashboard-task-view"); }); it("does not render insights view button when insights experimental feature is disabled", async () => { // Keep at least one overflow item enabled so the overflow trigger still renders. (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { insights: false }, }); render(); // Wait for the header to render await waitFor(() => { expect(screen.getByTitle("Board view")).toBeTruthy(); }); // Open the overflow menu - Insights item should not be rendered fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); expect(screen.queryByTestId("view-overflow-insights")).toBeNull(); }); it("keeps experimental views off until settings load and falls back to board when no flag is enabled", async () => { localStorage.setItem(taskViewStorageKey(), "insights"); let resolveSettings: ((settings: Settings) => void) | undefined; vi.mocked(fetchSettings).mockImplementationOnce( () => new Promise((resolve) => { resolveSettings = resolve as (settings: Settings) => void; }), ); render(); await waitFor(() => { expect(screen.getByTitle("Board view")).toBeTruthy(); }); expect(document.querySelector(".insights-view")).toBeNull(); expect(document.querySelector(".board")).toBeNull(); resolveSettings?.({ ...defaultSettings, experimentalFeatures: {}, }); await waitFor(() => { expect(document.querySelector(".board")).toBeTruthy(); }); expect(document.querySelector(".insights-view")).toBeNull(); localStorage.removeItem(taskViewStorageKey()); }); it("does not render memory view button when memoryView experimental feature is disabled", async () => { // Keep another overflow item enabled so the overflow trigger still renders. (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { memoryView: false, insights: true }, }); render(); // Wait for the header to render await waitFor(() => { expect(screen.getByTitle("Board view")).toBeTruthy(); }); // Open the overflow menu - Memory item should not be rendered fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); expect(screen.queryByTestId("view-toggle-memory")).toBeNull(); }); it("redirects to board when memoryView experimental feature is disabled and taskView is memory", async () => { // Set localStorage to memory view but memoryView is disabled localStorage.setItem(taskViewStorageKey(), "memory"); (fetchSettings as ReturnType).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { memoryView: false }, }); render(); // Wait for the app to settle await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); // Should redirect to board view since memory is disabled await waitFor(() => { expect(document.querySelector(".board")).toBeTruthy(); }); // Cleanup localStorage.removeItem(taskViewStorageKey()); }); }); describe("App GitHub import", () => { it("opens GitHub import modal when import button is clicked", async () => { render(); // Wait for the header to render await waitFor(() => { expect(screen.getByTitle("Import from GitHub")).toBeTruthy(); }); // Click the import button fireEvent.click(screen.getByTitle("Import from GitHub")); // Modal should be visible expect(screen.getByText("Import from GitHub")).toBeTruthy(); }); it("closes GitHub import modal on cancel", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Import from GitHub")).toBeTruthy(); }); // Open the modal fireEvent.click(screen.getByTitle("Import from GitHub")); // Scope interactions to the GitHub import modal to avoid clicking Cancel // buttons from other overlays (e.g. onboarding wizard). const modalHeading = await screen.findByRole("heading", { name: "Import from GitHub" }); const modalOverlay = modalHeading.closest(".modal-overlay"); expect(modalOverlay).toBeTruthy(); const cancelButton = within(modalOverlay as HTMLElement).getByRole("button", { name: /^Cancel$/i }); fireEvent.click(cancelButton); // Modal heading should be gone after cancel closes the overlay. await waitFor(() => { expect(screen.queryByRole("heading", { name: "Import from GitHub" })).toBeNull(); }); }); }); describe("App Planning Mode", () => { it("opens Planning Mode modal when plan button is clicked", async () => { render(); // Wait for the header to render await waitFor(() => { expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy(); }); // Click the plan button fireEvent.click(screen.getByTitle("Create a task with AI planning")); // Planning modal should be visible await waitFor(() => { expect(screen.getByText("Planning Mode")).toBeTruthy(); }); }); it("closes Planning Mode modal on close button click", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy(); }); // Open the modal fireEvent.click(screen.getByTitle("Create a task with AI planning")); await waitFor(() => { expect(screen.getByText("Planning Mode")).toBeTruthy(); }); // Close the modal using the close button fireEvent.click(screen.getByLabelText("Close")); // Modal should be closed await waitFor(() => { expect(screen.queryByText("Transform your idea into a detailed task")).toBeNull(); }); }); it("renders planning modal with correct initial state", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy(); }); // Open the modal fireEvent.click(screen.getByTitle("Create a task with AI planning")); // Initial view should show await waitFor(() => { expect(screen.getByText("Transform your idea into a detailed task")).toBeTruthy(); expect(screen.getByPlaceholderText(/e.g., Build a user authentication system with login/)).toBeTruthy(); expect(screen.getByText("Start Planning")).toBeTruthy(); }); }); }); describe("Script run flow", () => { it("calls runScript API and returns session info", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Settings")).toBeTruthy(); }); const { runScript: runScriptMock } = await import("../../api"); await act(async () => { const result = await runScriptMock("build", undefined, "proj_123"); expect(result).toEqual({ sessionId: "sess-script-1", command: "echo hello" }); }); expect(runScriptMock).toHaveBeenCalledWith("build", undefined, "proj_123"); }); it("shows error toast when runScript API fails", async () => { const { runScript: runScriptMock } = await import("../../api"); (runScriptMock as ReturnType).mockRejectedValueOnce(new Error("Script not found")); render(); await waitFor(() => { expect(screen.getByTitle("Settings")).toBeTruthy(); }); await act(async () => { try { await runScriptMock("missing-script", undefined, "proj_123"); } catch { // Expected to throw } }); expect(runScriptMock).toHaveBeenCalledWith("missing-script", undefined, "proj_123"); }); }); describe("Script-to-terminal modal handoff", () => { it("closes ScriptsModal and opens TerminalModal when Run is clicked", async () => { render(); // Wait for the app to fully render await waitFor(() => { expect(screen.getByTitle("Settings")).toBeTruthy(); }); // Open the Scripts modal via the quick-scripts dropdown "Manage Scripts..." button const scriptsBtn = screen.getByTestId("scripts-btn"); await act(async () => { fireEvent.click(scriptsBtn); }); // Wait for the dropdown menu to appear and click "Manage Scripts..." await waitFor(() => { expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy(); }); await act(async () => { fireEvent.click(screen.getByTestId("quick-scripts-manage")); }); // Wait for the Scripts modal to open and load scripts await waitFor(() => { expect(screen.getByTestId("scripts-modal")).toBeTruthy(); }); // Click the Run button on the "build" script await act(async () => { fireEvent.click(screen.getByTestId("run-script-build")); }); // The Scripts modal should now be closed await waitFor(() => { expect(screen.queryByTestId("scripts-modal")).toBeNull(); }); // The TerminalModal should be open (not the old ScriptRunDialog) await waitFor(() => { expect(screen.getByTestId("terminal-modal")).toBeTruthy(); }); // ScriptRunDialog should NOT be rendered at all expect(screen.queryByTestId("script-run-dialog-overlay")).toBeNull(); }); it("allows reopening ScriptsModal after closing TerminalModal", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Settings")).toBeTruthy(); }); // Open the Scripts modal and run a script const scriptsBtn = screen.getByTestId("scripts-btn"); await act(async () => { fireEvent.click(scriptsBtn); }); await waitFor(() => { expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy(); }); await act(async () => { fireEvent.click(screen.getByTestId("quick-scripts-manage")); }); await waitFor(() => { expect(screen.getByTestId("scripts-modal")).toBeTruthy(); }); await act(async () => { fireEvent.click(screen.getByTestId("run-script-build")); }); // Wait for TerminalModal to open await waitFor(() => { expect(screen.getByTestId("terminal-modal")).toBeTruthy(); }); expect(screen.queryByTestId("scripts-modal")).toBeNull(); // Close the TerminalModal await act(async () => { fireEvent.click(screen.getByTestId("terminal-close-btn")); }); // TerminalModal should be closed await waitFor(() => { expect(screen.queryByTestId("terminal-modal")).toBeNull(); }); // Reopen the Scripts modal await act(async () => { fireEvent.click(scriptsBtn); }); await waitFor(() => { expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy(); }); await act(async () => { fireEvent.click(screen.getByTestId("quick-scripts-manage")); }); // Scripts modal should open again cleanly await waitFor(() => { expect(screen.getByTestId("scripts-modal")).toBeTruthy(); }); }); it("does not call runScript API — command is sent directly to terminal", async () => { render(); await waitFor(() => { expect(screen.getByTitle("Settings")).toBeTruthy(); }); // Open the Scripts modal const scriptsBtn = screen.getByTestId("scripts-btn"); await act(async () => { fireEvent.click(scriptsBtn); }); await waitFor(() => { expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy(); }); await act(async () => { fireEvent.click(screen.getByTestId("quick-scripts-manage")); }); await waitFor(() => { expect(screen.getByTestId("scripts-modal")).toBeTruthy(); }); // Click Run on the "build" script await act(async () => { fireEvent.click(screen.getByTestId("run-script-build")); }); // TerminalModal should open await waitFor(() => { expect(screen.getByTestId("terminal-modal")).toBeTruthy(); }); // runScript API should NOT have been called — command goes directly to terminal expect(runScript).not.toHaveBeenCalled(); }); }); describe("App footer-safe project layout", () => { afterEach(() => { localStorage.removeItem("kb-dashboard-view-mode"); localStorage.removeItem(taskViewStorageKey()); }); it("wraps project content in project-content div with footer class when project is selected", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); render(); await waitFor(() => { const wrapper = document.querySelector(".project-content--with-footer"); expect(wrapper).toBeTruthy(); expect(wrapper?.classList.contains("project-content")).toBe(true); }); // The board should be inside the footer-safe wrapper const wrapper = document.querySelector(".project-content--with-footer"); expect(wrapper?.querySelector(".board")).toBeTruthy(); }); it("opens the built-in file browser from the footer project directory link", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); mockProjectsState.projects = [mockCurrentProjectState.currentProject]; render(); await waitFor(() => { expect(screen.getByTestId("executor-project-path-toggle")).toBeTruthy(); }); fireEvent.click(screen.getByTestId("executor-project-path-toggle")); await waitFor(() => { expect(screen.getByTestId("executor-project-path-link")).toHaveTextContent("/test"); }); fireEvent.click(screen.getByTestId("executor-project-path-link")); await waitFor(() => { expect(screen.getByText("Files — Project")).toBeTruthy(); }); }); it("uses project-content wrapper without footer class in overview mode", async () => { mockCurrentProjectState.currentProject = null; mockProjectsState.projects = []; localStorage.setItem("kb-dashboard-view-mode", "overview"); render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); await waitFor(() => { const wrapper = document.querySelector(".project-content"); expect(wrapper).toBeTruthy(); expect(wrapper?.classList.contains("project-content--with-footer")).toBe(false); }); }); it("adds and removes footer class when switching between project and overview", async () => { // Start in project mode localStorage.setItem("kb-dashboard-view-mode", "project"); const { rerender } = render(); await waitFor(() => { expect(document.querySelector(".project-content--with-footer")).toBeTruthy(); }); // Switch to overview by clearing project mockCurrentProjectState.currentProject = null; mockProjectsState.projects = []; rerender(); await waitFor(() => { const wrapper = document.querySelector(".project-content"); expect(wrapper).toBeTruthy(); expect(wrapper?.classList.contains("project-content--with-footer")).toBe(false); }); }); it("renders agents view inside the footer-safe wrapper", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "agents"); render(); await waitFor(() => { const wrapper = document.querySelector(".project-content--with-footer"); expect(wrapper).toBeTruthy(); expect(wrapper?.querySelector(".agents-view")).toBeTruthy(); }); }); it("renders list view inside the footer-safe wrapper", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "list"); render(); await waitFor(() => { const wrapper = document.querySelector(".project-content--with-footer"); expect(wrapper).toBeTruthy(); expect(wrapper?.querySelector(".list-view")).toBeTruthy(); }); }); /** * FN-824: The board must be a child of the footer-safe wrapper so that * its height is constrained by the wrapper's available space (which * already reserves room for the fixed ExecutorStatusBar via padding-bottom). * On mobile, the board previously used calc(100dvh - 57px) which bypassed * the wrapper and extended under the footer bar. */ it("renders board inside the footer-safe wrapper (FN-824 mobile regression)", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); localStorage.setItem(taskViewStorageKey(), "board"); render(); await waitFor(() => { const wrapper = document.querySelector(".project-content--with-footer"); expect(wrapper).toBeTruthy(); // Board is a direct child of the footer-safe wrapper const board = wrapper?.querySelector(".board"); expect(board).toBeTruthy(); // Verify the board is a descendant of the wrapper (not a sibling) expect(wrapper?.contains(board!)).toBe(true); }); }); }); describe("App node mode switching", () => { it("does not render node selector when no remote nodes are available", async () => { render(); await waitForAppShell(); // Node selector should not be visible when no remote nodes available expect(screen.queryByTestId("node-selector-trigger")).toBeNull(); }); it("renders node selector trigger when remote nodes are available", async () => { // Get the mocked useNodes and set up the return value const { useNodes } = await import("../../hooks/useNodes"); vi.mocked(useNodes).mockReturnValue({ nodes: [ { id: "node_remote_1", name: "Remote Node 1", type: "remote" as const, url: "http://remote:4040", status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "", }, ], loading: false, error: null, refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(), }); render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); expect(screen.getByTestId("node-selector-trigger")).toBeInTheDocument(); }); // Node selector trigger should be visible when remote nodes available expect(screen.getByTestId("node-selector-trigger")).toBeInTheDocument(); }); it("shows remote node name when remote node is selected", async () => { // Get the mocked useNodes and set up the return value const { useNodes } = await import("../../hooks/useNodes"); vi.mocked(useNodes).mockReturnValue({ nodes: [ { id: "node_remote_1", name: "Remote Node 1", type: "remote" as const, url: "http://remote:4040", status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "", }, ], loading: false, error: null, refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(), }); // Mock node context to return remote node mockNodeContextValue.currentNode = { id: "node_remote_1", name: "Remote Node 1", type: "remote", url: "http://remote:4040", status: "online", maxConcurrent: 2, createdAt: "", updatedAt: "", }; mockNodeContextValue.currentNodeId = "node_remote_1"; mockNodeContextValue.isRemote = true; render(); // Should show remote node name await waitFor(() => { expect(screen.getByText("Remote Node 1")).toBeInTheDocument(); }); }); it("calls clearCurrentNode when Local option is selected", async () => { // Get the mocked useNodes and set up the return value const { useNodes } = await import("../../hooks/useNodes"); vi.mocked(useNodes).mockReturnValue({ nodes: [ { id: "node_remote_1", name: "Remote Node 1", type: "remote" as const, url: "http://remote:4040", status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "", }, ], loading: false, error: null, refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(), }); // Mock node context to return remote node mockNodeContextValue.currentNode = { id: "node_remote_1", name: "Remote Node 1", type: "remote", url: "http://remote:4040", status: "online", maxConcurrent: 2, createdAt: "", updatedAt: "", }; mockNodeContextValue.currentNodeId = "node_remote_1"; mockNodeContextValue.isRemote = true; render(); await waitFor(() => { expect(screen.getByText("Remote Node 1")).toBeInTheDocument(); }); // Open the node selector fireEvent.click(screen.getByTestId("node-selector-trigger")); await waitFor(() => { expect(screen.getByTestId("node-option-local")).toBeInTheDocument(); }); // Click Local option fireEvent.click(screen.getByTestId("node-option-local")); await waitFor(() => { expect(mockNodeContextValue.clearCurrentNode).toHaveBeenCalled(); }); }); it("calls setCurrentNode when a remote node is selected", async () => { // Get the mocked useNodes and set up the return value const { useNodes } = await import("../../hooks/useNodes"); vi.mocked(useNodes).mockReturnValue({ nodes: [ { id: "node_remote_1", name: "Remote Node 1", type: "remote" as const, url: "http://remote:4040", status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "", }, { id: "node_remote_2", name: "Remote Node 2", type: "remote" as const, url: "http://remote2:4040", status: "offline" as const, maxConcurrent: 2, createdAt: "", updatedAt: "", }, ], loading: false, error: null, refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(), }); render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); expect(screen.getByTestId("node-selector-trigger")).toBeInTheDocument(); }); // Open the node selector fireEvent.click(screen.getByTestId("node-selector-trigger")); await waitFor(() => { expect(screen.getByTestId("node-option-node_remote_2")).toBeInTheDocument(); }); // Click the second remote node fireEvent.click(screen.getByTestId("node-option-node_remote_2")); await waitFor(() => { expect(mockNodeContextValue.setCurrentNode).toHaveBeenCalledWith({ id: "node_remote_2", name: "Remote Node 2", type: "remote", url: "http://remote2:4040", status: "offline", maxConcurrent: 2, createdAt: "", updatedAt: "", }); }); }); }); describe("App search query propagation to remote mode", () => { // Mock useRemoteNodeData to capture searchQuery parameter let capturedSearchQuery: string | undefined; beforeEach(() => { capturedSearchQuery = undefined; // Get the mocked useRemoteNodeData and capture the searchQuery vi.mocked(apiNodeModule.useRemoteNodeData).mockImplementation((nodeId, options) => { capturedSearchQuery = options?.searchQuery; return { projects: [], tasks: [], health: null, loading: false, error: null, refresh: vi.fn(), }; }); }); afterEach(() => { vi.clearAllMocks(); localStorage.removeItem("fusion-dashboard-current-node"); }); it("passes searchQuery to useRemoteNodeData when in remote mode", async () => { // Set up mock with remote node const { useNodes } = await import("../../hooks/useNodes"); vi.mocked(useNodes).mockReturnValue({ nodes: [ { id: "node_remote_1", name: "Remote Node 1", type: "remote" as const, url: "http://remote:4040", status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "", }, ], loading: false, error: null, refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(), }); // Mock node context to return remote node mockNodeContextValue.currentNode = { id: "node_remote_1", name: "Remote Node 1", type: "remote", url: "http://remote:4040", status: "online", maxConcurrent: 2, createdAt: "", updatedAt: "", }; mockNodeContextValue.currentNodeId = "node_remote_1"; mockNodeContextValue.isRemote = true; // Set project mode localStorage.setItem("kb-dashboard-view-mode", "project"); render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); expect(screen.getByTestId("desktop-header-search-btn")).toBeInTheDocument(); }); // At this point, searchQuery should be passed to useRemoteNodeData // capturedSearchQuery should be undefined (empty search initially) expect(capturedSearchQuery).toBeUndefined(); }); it("updates searchQuery in useRemoteNodeData when header search changes", async () => { // Set up mock with remote node const { useNodes } = await import("../../hooks/useNodes"); vi.mocked(useNodes).mockReturnValue({ nodes: [ { id: "node_remote_1", name: "Remote Node 1", type: "remote" as const, url: "http://remote:4040", status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "", }, ], loading: false, error: null, refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(), }); // Mock node context to return remote node mockNodeContextValue.currentNode = { id: "node_remote_1", name: "Remote Node 1", type: "remote", url: "http://remote:4040", status: "online", maxConcurrent: 2, createdAt: "", updatedAt: "", }; mockNodeContextValue.currentNodeId = "node_remote_1"; mockNodeContextValue.isRemote = true; // Set project mode localStorage.setItem("kb-dashboard-view-mode", "project"); render(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); expect(screen.getByTestId("desktop-header-search-btn")).toBeInTheDocument(); }); // Click the search toggle button to open search const searchToggleBtn = screen.getByTestId("desktop-header-search-btn"); expect(searchToggleBtn).toBeInTheDocument(); fireEvent.click(searchToggleBtn); // Now the search input should be visible const searchInput = await screen.findByPlaceholderText("Search tasks..."); expect(searchInput).toBeInTheDocument(); // Type in the search input fireEvent.change(searchInput, { target: { value: "test search" } }); // Wait for the search query to propagate await waitFor(() => { expect(capturedSearchQuery).toBe("test search"); }); }); }); describe("App onboarding reopen", () => { beforeEach(() => { // Reset mocks before each test vi.clearAllMocks(); }); it("does not auto-open onboarding when modelOnboardingComplete is true and setup is complete", async () => { // Mock fetchGlobalSettings to return complete onboarding with default model (fetchGlobalSettings as ReturnType).mockResolvedValue({ modelOnboardingComplete: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }); render(); await waitForAppShell(); // Onboarding modal should NOT be open expect(screen.queryByText("Set Up AI")).toBeNull(); }); it("opens Settings → Authentication → Reopen onboarding guide opens onboarding modal", async () => { // Mock fetchGlobalSettings to return complete onboarding (to avoid auto-open on first call) // and hydrated settings on subsequent calls (fetchGlobalSettings as ReturnType) .mockResolvedValueOnce({ modelOnboardingComplete: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }) .mockResolvedValue({ modelOnboardingComplete: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }); // Mock Settings and auth (fetchSettings as ReturnType).mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, }); (fetchAuthStatus as ReturnType).mockResolvedValue({ providers: [ { id: "anthropic", name: "Anthropic", authenticated: true }, ], }); (fetchModels as ReturnType).mockResolvedValue({ models: [ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 }, ], favoriteProviders: [], favoriteModels: [], }); render(); await waitForAppShell(); // Onboarding should NOT be open initially expect(screen.queryByText("Set Up AI")).toBeNull(); // Open Settings via header const settingsBtn = screen.getByRole("button", { name: /settings/i }); fireEvent.click(settingsBtn); await waitFor(() => { expect(screen.getByText("Settings")).toBeTruthy(); }); // Navigate to Authentication section (it should be default or click to ensure) const authSections = await screen.findAllByText("Authentication"); const authSection = authSections[0]; fireEvent.click(authSection); await waitFor(() => { expect(fetchAuthStatus).toHaveBeenCalled(); }); // Click Reopen onboarding guide button const reopenBtn = screen.getByText("Reopen onboarding guide"); fireEvent.click(reopenBtn); // Onboarding modal should now be open await waitFor(() => { expect(screen.getByText("Set Up AI")).toBeTruthy(); }); }); it("reopened modal shows hydrated model state from global settings", async () => { // Mock fetchGlobalSettings to return hydrated settings (fetchGlobalSettings as ReturnType).mockResolvedValue({ modelOnboardingComplete: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }); // Mock Settings and auth (fetchSettings as ReturnType).mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, }); (fetchAuthStatus as ReturnType).mockResolvedValue({ providers: [ { id: "anthropic", name: "Anthropic", authenticated: true }, ], }); (fetchModels as ReturnType).mockResolvedValue({ models: [ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 }, ], favoriteProviders: [], favoriteModels: [], }); render(); await waitForAppShell(); // Open Settings via header const settingsBtn = screen.getByRole("button", { name: /settings/i }); fireEvent.click(settingsBtn); await waitFor(() => { expect(screen.getByText("Settings")).toBeTruthy(); }); // Navigate to Authentication section const authSections = await screen.findAllByText("Authentication"); const authSection = authSections[0]; fireEvent.click(authSection); await waitFor(() => { expect(fetchAuthStatus).toHaveBeenCalled(); }); // Click Reopen onboarding guide button from Authentication section const reopenBtn = await screen.findByText("Reopen onboarding guide"); fireEvent.click(reopenBtn); // Wait for onboarding modal to open await waitFor(() => { expect(screen.getByText("Set Up AI")).toBeTruthy(); }); // The model dropdown should be pre-populated with the saved default // Check that the dropdown shows the saved model is selected const dropdown = await screen.findByTestId("mock-model-dropdown"); expect((dropdown as HTMLSelectElement).value).toBe("anthropic/claude-sonnet-4-5"); }); }); describe("App auth token recovery dialog", () => { it("opens as a non-dismissable blocking dialog when daemon auth recovery is required", async () => { render(); await waitForAppShell(); expect(screen.queryByRole("dialog", { name: "Authentication token required" })).toBeNull(); act(() => { window.dispatchEvent(new CustomEvent(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT)); }); const dialog = await screen.findByRole("dialog", { name: "Authentication token required" }); expect(dialog).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /close/i })).toBeNull(); const overlay = dialog.closest(".auth-token-recovery-overlay"); expect(overlay).toBeTruthy(); if (!overlay) { throw new Error("Expected auth token recovery overlay to be present"); } fireEvent.keyDown(overlay, { key: "Escape" }); fireEvent.click(overlay); expect(screen.getByRole("dialog", { name: "Authentication token required" })).toBeInTheDocument(); }); }); describe("FN-3290: modal keyboard isolation for mobile dashboard layout", () => { const originalLocation = window.location; beforeEach(() => { window.history.replaceState = vi.fn(); // Prevent onboarding modal from auto-opening (fetchAuthStatus as ReturnType).mockResolvedValue({ providers: [ { id: "anthropic", name: "Anthropic", authenticated: true }, ], }); (fetchGlobalSettings as ReturnType).mockResolvedValue({ modelOnboardingComplete: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }); }); afterEach(() => { Object.defineProperty(window, "location", { configurable: true, value: originalLocation, }); localStorage.removeItem("kb-dashboard-view-mode"); localStorage.removeItem(taskViewStorageKey()); }); it("removes project-content--with-mobile-nav when keyboard is open with no modal (mobile)", async () => { mockUseViewportMode.mockReturnValue("mobile"); localStorage.setItem("kb-dashboard-view-mode", "project"); // Keyboard is open, no modal mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 250, viewportHeight: 550, viewportOffsetTop: 0, keyboardOpen: true, }); render(); await waitFor(() => { expect(document.querySelector(".project-content")).toBeTruthy(); }); const wrapper = document.querySelector(".project-content"); // Without a modal, the keyboard-open state should remove the mobile nav padding expect(wrapper?.classList.contains("project-content--with-mobile-nav")).toBe(false); }); it("keeps project-content--with-mobile-nav when keyboard is open inside a modal (mobile)", async () => { // Use deep link to open a task detail modal — avoids complex mobile overflow navigation Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-123"), }); mockUseViewportMode.mockReturnValue("mobile"); localStorage.setItem("kb-dashboard-view-mode", "project"); // Keyboard is reported as open (as if a modal input has focus) mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 250, viewportHeight: 550, viewportOffsetTop: 0, keyboardOpen: true, }); render(); // Wait for task detail modal to open await waitFor(() => { expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123"); }); await waitFor(() => { expect(screen.getByText("Task FN-123")).toBeTruthy(); }); // The dashboard wrapper should STILL have project-content--with-mobile-nav // because the keyboard-open state is gated by anyModalOpen. const wrapper = document.querySelector(".project-content"); expect(wrapper).toBeTruthy(); expect(wrapper?.classList.contains("project-content--with-mobile-nav")).toBe(true); }); it("removes mobile nav class when modal closes while keyboard stays open", async () => { Object.defineProperty(window, "location", { configurable: true, value: new URL("http://localhost:3000/?task=FN-456"), }); mockUseViewportMode.mockReturnValue("mobile"); localStorage.setItem("kb-dashboard-view-mode", "project"); mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 250, viewportHeight: 550, viewportOffsetTop: 0, keyboardOpen: true, }); const { rerender } = render(); // Wait for task detail modal to open await waitFor(() => { expect(screen.getByText("Task FN-456")).toBeTruthy(); }); // With modal open, mobile nav class is preserved despite keyboard being open let wrapper = document.querySelector(".project-content"); expect(wrapper?.classList.contains("project-content--with-mobile-nav")).toBe(true); // Close the modal via close button const closeBtn = document.querySelector(".modal-overlay.open .modal-close") as HTMLElement; expect(closeBtn).toBeTruthy(); fireEvent.click(closeBtn); rerender(); // Keyboard is still open, but modal is now closed — mobileKeyboardOpen becomes true, // so the mobile nav class should be removed await waitFor(() => { wrapper = document.querySelector(".project-content"); expect(wrapper?.classList.contains("project-content--with-mobile-nav")).toBe(false); }); }); }); describe("App board branch filters", () => { const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter"; const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter"; function scopedProjectKey(baseKey: string, projectId: string) { return `kb:${projectId}:${baseKey}`; } function makeTask(id: string, title: string, branch?: string, baseBranch?: string) { return { id, title, description: title, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...(branch ? { branch } : {}), ...(baseBranch ? { baseBranch } : {}), }; } it("filters board tasks by working and target branch in local mode", async () => { mockUseTasks.mockImplementation(() => ({ tasks: [ makeTask("FN-1", "Task Alpha", "feature/a", "main"), makeTask("FN-2", "Task Beta", "feature/b", "release"), ], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), })); render(); await waitForAppShell(); fireEvent.click(screen.getByTestId("desktop-header-search-btn")); fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "feature/a" } }); fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "main" } }); await waitFor(() => { expect(screen.getByText("Task Alpha")).toBeTruthy(); expect(screen.queryByText("Task Beta")).toBeNull(); }); }); it("supports filtering for tasks without working branch values", async () => { mockUseTasks.mockImplementation(() => ({ tasks: [ makeTask("FN-1", "Unassigned Task"), makeTask("FN-2", "Assigned Task", "feature/a", "main"), ], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), })); render(); await waitForAppShell(); fireEvent.click(screen.getByTestId("desktop-header-search-btn")); fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "__fusion:no-branch__" } }); await waitFor(() => { expect(screen.getByText("Unassigned Task")).toBeTruthy(); expect(screen.queryByText("Assigned Task")).toBeNull(); }); }); it("supports filtering for tasks without base branch values", async () => { mockUseTasks.mockImplementation(() => ({ tasks: [ makeTask("FN-1", "No Base Branch", "feature/a"), makeTask("FN-2", "Has Base Branch", "feature/a", "main"), ], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), })); render(); await waitForAppShell(); fireEvent.click(screen.getByTestId("desktop-header-search-btn")); fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "__fusion:no-branch__" } }); await waitFor(() => { expect(screen.getByText("No Base Branch")).toBeTruthy(); expect(screen.queryByText("Has Base Branch")).toBeNull(); }); }); it("derives branch filter options from remote task data in remote mode", async () => { mockNodeContextValue.isRemote = true; mockNodeContextValue.currentNodeId = "node-1"; const remoteSpy = vi.spyOn(apiNodeModule, "useRemoteNodeData").mockReturnValue({ projects: [], tasks: [makeTask("FN-3", "Remote Task", "feature/remote", "develop")], health: null, loading: false, error: null, refresh: vi.fn(), }); render(); await waitForAppShell(); fireEvent.click(screen.getByTestId("desktop-header-search-btn")); expect(screen.getByRole("option", { name: "feature/remote" })).toBeTruthy(); expect(screen.getByRole("option", { name: "develop" })).toBeTruthy(); remoteSpy.mockRestore(); }); it("restores saved branch filter selections per project", async () => { const projectId = "project-restore"; mockCurrentProjectState.currentProject = { id: projectId, name: "Restore Project", path: "/restore", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "", }; localStorage.setItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectId), "feature/a"); localStorage.setItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectId), "__fusion:no-branch__"); mockUseTasks.mockImplementation(() => ({ tasks: [ makeTask("FN-1", "Restore Candidate", "feature/a"), makeTask("FN-2", "Filtered Out", "feature/b", "main"), ], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), })); render(); await waitForAppShell(); fireEvent.click(screen.getByTestId("desktop-header-search-btn")); expect((screen.getByTestId("working-branch-filter") as HTMLSelectElement).value).toBe("feature/a"); expect((screen.getByTestId("target-branch-filter") as HTMLSelectElement).value).toBe("__fusion:no-branch__"); await waitFor(() => { expect(screen.getByText("Restore Candidate")).toBeTruthy(); expect(screen.queryByText("Filtered Out")).toBeNull(); }); }); it("writes updated filter values to project-scoped storage and isolates between projects", async () => { const projectOneId = "project-one"; const projectTwoId = "project-two"; mockCurrentProjectState.currentProject = { id: projectOneId, name: "Project One", path: "/one", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "", }; mockUseTasks.mockImplementation(() => ({ tasks: [makeTask("FN-1", "Alpha Search", "feature/a", "main")], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), })); const { rerender } = render(); await waitForAppShell(); fireEvent.click(screen.getByTestId("desktop-header-search-btn")); fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "feature/a" } }); fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "main" } }); expect(localStorage.getItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectOneId))).toBe("feature/a"); expect(localStorage.getItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectOneId))).toBe("main"); mockCurrentProjectState.currentProject = { id: projectTwoId, name: "Project Two", path: "/two", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "", }; rerender(); await waitForAppShell(); fireEvent.click(screen.getByTestId("desktop-header-search-btn")); expect((screen.getByTestId("working-branch-filter") as HTMLSelectElement).value).toBe(""); expect((screen.getByTestId("target-branch-filter") as HTMLSelectElement).value).toBe(""); expect(localStorage.getItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectTwoId))).toBeNull(); expect(localStorage.getItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectTwoId))).toBeNull(); }); it("composes with search and does not affect list view tasks", async () => { mockUseTasks.mockImplementation(() => ({ tasks: [ makeTask("FN-4", "Alpha Search", "feature/a", "main"), makeTask("FN-5", "Beta Search", "feature/b", "main"), ], createTask: mockCreateTask, moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(), archiveAllDone: vi.fn(), })); render(); await waitForAppShell(); fireEvent.click(screen.getByTestId("desktop-header-search-btn")); fireEvent.change(screen.getByPlaceholderText("Search tasks..."), { target: { value: "Search" } }); fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "feature/a" } }); await waitFor(() => { expect(screen.getByText("Alpha Search")).toBeTruthy(); expect(screen.queryByText("Beta Search")).toBeNull(); }); fireEvent.click(screen.getByTitle("List view")); await waitFor(() => { expect(screen.getByText("Alpha Search")).toBeTruthy(); expect(screen.getByText("Beta Search")).toBeTruthy(); }); }); }); describe("App shell connection status plumbing", () => { it("loads shell connection status for native shell host", async () => { mockShellHostContextValue.host = { kind: "desktop-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" }; mockGetShellConnectionNativeResult.mockResolvedValueOnce({ hostKind: "desktop-shell", available: true, mode: "remote", profileLabel: "Prod", serverOrigin: "https://fusion.example.com", openConnectionManager: async () => ({ ok: true }), }); render(); await waitFor(() => { expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host); expect(screen.getByTestId("shell-connection-status-button")).toBeInTheDocument(); }); }); it("does not render shell connection status in browser mode", async () => { mockShellHostContextValue.host = { kind: "browser" }; mockGetShellConnectionNativeResult.mockResolvedValueOnce({ hostKind: "browser", available: false, openConnectionManager: async () => ({ ok: false, reason: "unsupported" }), }); render(); await waitFor(() => { expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host); }); expect(screen.queryByTestId("shell-connection-status-button")).toBeNull(); }); it("renders shell connection status for mobile shell host in mobile More sheet only", async () => { mockUseViewportMode.mockReturnValue("mobile"); mockShellHostContextValue.host = { kind: "mobile-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" }; mockGetShellConnectionNativeResult.mockResolvedValueOnce({ hostKind: "mobile-shell", available: true, mode: "remote", profileLabel: "Mobile", serverOrigin: "https://fusion.example.com", openConnectionManager: async () => ({ ok: true }), }); render(); await waitFor(() => { expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host); expect(screen.getByTestId("mobile-nav-tab-more")).toBeInTheDocument(); }); expect(screen.queryByTestId("shell-connection-status-button")).toBeNull(); fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1); expect(screen.getByTestId("mobile-more-shell-connection")).toBeInTheDocument(); }); it("keeps desktop shell connection status in header and out of mobile sheet", async () => { mockUseViewportMode.mockReturnValue("desktop"); mockShellHostContextValue.host = { kind: "desktop-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" }; mockGetShellConnectionNativeResult.mockResolvedValueOnce({ hostKind: "desktop-shell", available: true, mode: "remote", profileLabel: "Prod", serverOrigin: "https://fusion.example.com", openConnectionManager: async () => ({ ok: true }), }); render(); await waitFor(() => { expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1); }); expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull(); }); });