Files
fusion/packages/dashboard/app/components/__tests__/App.test.tsx
gsxdsm ed5417a341 FN-7529: fix desktop header search test
Update the App test to assert the open desktop search panel state after rerender.\n\n- Avoid re-clicking the desktop search toggle after the non-mobile panel stays open across rerender.\n- Assert the branch-filter selects directly while the search panel remains rendered.\n\nFiles changed:\n packages/dashboard/app/components/__tests__/App.test.tsx | 9 +++++++--\n 1 file changed, 7 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7529

Fusion-Task-Lineage: 1495f872-d262-4ea4-83f2-5a53ecf7a202

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-04 13:57:20 -07:00

4568 lines
154 KiB
TypeScript

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 <div data-testid={testId}>{status}</div>;
}
const defaultSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
recycleWorktrees: false,
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
capacityRiskBannerEnabled: false,
capacityRiskTodoThreshold: 20,
/*
* FNXC:DashboardTests 2026-06-25-10:52:
* App.test.tsx now mirrors the shipped left-sidebar navigation default because graduated destinations (Insights, Memory, Todo, Goals, Agents) must stay visible even when stale experimental flags are false. Individual legacy header-toggle tests opt out explicitly instead of making the whole fixture hide sidebar controls.
*/
experimentalFeatures: { insights: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true, leftSidebarNav: true },
};
const mockAgentStats = {
activeCount: 0,
busyCount: 0,
pausedCount: 0,
todoTaskCount: 0,
idleNonEphemeralCount: 1,
};
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<typeof import("../../api")>(), {
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([])),
fetchAgentStats: vi.fn(() => Promise.resolve({ ...mockAgentStats })),
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,
engine: {
available: true,
},
database: {
healthy: true,
corruptionDetected: false,
corruptionErrors: [],
lastCheckedAt: null,
isRunning: false,
},
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
})),
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
fetchBoardWorkflows: vi.fn(() => Promise.resolve({
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
})),
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(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
// 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<typeof vi.fn>;
clearCurrentNode: ReturnType<typeof vi.fn>;
} = {
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,
};
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", "agent", "first-task"],
}));
// Mock CustomModelDropdown for onboarding modal tests
vi.mock("../../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
<select
data-testid="mock-model-dropdown"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder ?? "Select…"}</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
),
}));
vi.mock("../../components/TaskDetailModal", () => ({
TaskDetailModal: ({ task, onClose }: { task: { id: string; title?: string }; onClose: () => void }) => (
<div className="modal-overlay open">
<div role="dialog" aria-label={task.title ?? task.id}>
<button type="button" className="modal-close" onClick={onClose}>
Close
</button>
<h2>{task.title ?? task.id}</h2>
</div>
</div>
),
TaskDetailContent: ({ task, onBackToBoard, onOpenDetail }: { task: { id: string; title?: string }; onBackToBoard?: () => void; onOpenDetail?: (task: { id: string; title?: string }) => void }) => (
<section data-testid="main-panel-task-detail">
<button type="button" onClick={onBackToBoard}>Back to board</button>
<h2>{task.title ?? task.id}</h2>
<button type="button" onClick={() => onOpenDetail?.({ id: "FN-6965", title: "Nested task" })}>Open nested task</button>
</section>
),
}));
vi.mock("../../components/GitHubImportModal", () => ({
// Embedded presentation (sidebar "Import Tasks" destination) drops the modal
// overlay + Cancel button; modal presentation (mobile overflow path) keeps them.
GitHubImportModal: ({ isOpen, onClose, presentation = "modal" }: { isOpen: boolean; onClose: () => void; presentation?: "modal" | "embedded" }) =>
isOpen ? (
<div
className={presentation === "embedded" ? "github-import-modal github-import-modal--embedded open" : "modal-overlay open"}
data-testid={presentation === "embedded" ? "github-import-view" : undefined}
>
<h2>Import from GitHub</h2>
{presentation === "embedded" ? null : (
<button type="button" onClick={onClose}>
Cancel
</button>
)}
</div>
) : null,
}));
vi.mock("../../components/PlanningModeModal", () => ({
PlanningModeModal: ({ isOpen, onClose, presentation = "modal" }: { isOpen: boolean; onClose: () => void; presentation?: "modal" | "embedded" }) =>
isOpen ? (
<div className={presentation === "embedded" ? "planning-view open" : "modal-overlay open"} data-testid={presentation === "embedded" ? "planning-view" : undefined}>
<button type="button" aria-label="Close" onClick={onClose}>
Close
</button>
<h2>Planning Mode</h2>
<p>Transform your idea into a detailed task</p>
<input placeholder="e.g., Build a user authentication system with login" />
<button type="button">Start Planning</button>
</div>
) : null,
}));
vi.mock("../../components/ScriptsModal", () => ({
ScriptsModal: ({
isOpen,
onClose,
onRunScript,
}: {
isOpen: boolean;
onClose: () => void;
onRunScript: (scriptName: string) => void;
}) =>
isOpen ? (
<div className="modal-overlay open" data-testid="scripts-modal">
<button type="button" onClick={onClose}>
Close
</button>
<button type="button" data-testid="run-script-build" onClick={() => onRunScript("build")}>
Run build
</button>
</div>
) : null,
}));
vi.mock("../../components/TerminalModal", () => ({
TerminalModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
isOpen ? (
<div className="modal-overlay open" data-testid="terminal-modal">
<button type="button" data-testid="terminal-close-btn" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock("../../components/AgentsView", () => ({
AgentsView: () => <div className="agents-view">Agents view</div>,
}));
vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({
DependencyGraphDashboardView: () => <div data-testid="dependency-graph">No active tasks to display in graph view.</div>,
}));
vi.mock("../../components/ResearchView", () => ({
ResearchView: ({ addToast }: { addToast?: (message: string, type?: "success" | "error" | "info") => void }) => (
<div data-testid="research-view">
<h2>Research</h2>
<p data-testid="research-status">completed</p>
<button type="button" onClick={() => addToast?.("Task created from research", "success")}>Create Task</button>
</div>
),
}));
vi.mock("../../components/EvalsView", () => ({
EvalsView: () => <div data-testid="evals-view">Evals</div>,
}));
vi.mock("../../components/TodoView", () => ({
TodoView: ({ onPlanningMode }: { onPlanningMode?: (initialPlan: string) => void }) => (
<div className="todo-view" data-testid="todo-view">
<button type="button" data-testid="todo-planning-button" onClick={() => onPlanningMode?.("Seed from todo")}>Plan from todo</button>
</div>
),
}));
vi.mock("../../components/GoalsView", () => ({
GoalsView: () => <div data-testid="goals-view">Goals View</div>,
}));
vi.mock("../../components/ChatView", () => ({
ChatView: () => <FileBrowserProbe testId="fb-probe-chat" />,
}));
vi.mock("../../components/DashboardLoader", () => ({
DashboardLoader: () => <FileBrowserProbe testId="fb-probe-loader" />,
}));
vi.mock("../../components/QuickChatFAB", () => ({
QuickChatFAB: () => null,
}));
vi.mock("../../components/SetupWizardModal", () => ({
SetupWizardModal: () => <div className="modal-overlay open">Welcome to Fusion</div>,
}));
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<Array<{ id: string; name: string }>>([]);
React.useEffect(() => {
void api.fetchSettings();
void api.fetchAuthStatus().then((result) => {
setProviders(result.providers ?? []);
});
}, []);
return (
<div className="modal-overlay open">
<h2>Settings</h2>
<button type="button" onClick={onClose}>
Close
</button>
<button type="button" onClick={() => setSection("authentication")}>
Authentication
</button>
<button type="button" onClick={() => setSection("general")}>
General
</button>
{section === "authentication" ? (
<div>
{providers.map((provider) => (
<div key={provider.id}>{provider.name}</div>
))}
<button type="button" onClick={onReopenOnboarding}>
Reopen onboarding guide
</button>
</div>
) : (
<label>
Task Prefix
<input aria-label="Task Prefix" />
</label>
)}
</div>
);
}
// FNXC:Settings 2026-06-22-12:00: Settings opens as an embedded main-content view (SettingsView) reusing the same body.
return { SettingsModal: MockSettingsModal, SettingsView: 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 (
<div className="modal-overlay open">
<h2>Set Up AI</h2>
<button type="button" onClick={onComplete}>
Skip for now
</button>
<select
data-testid="mock-model-dropdown"
value={value}
onChange={(e) => setValue(e.target.value)}
>
<option value="">Select…</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="openai/gpt-4o">GPT-4o</option>
</select>
</div>
);
}
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 DEFAULT_PROJECT: ProjectInfo = { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
const taskViewStorageKey = (projectId = DEFAULT_PROJECT_ID) =>
scopedKey("kb-dashboard-task-view", projectId);
const mockCurrentProjectState: {
currentProject: ProjectInfo | null;
setCurrentProject: ReturnType<typeof vi.fn>;
clearCurrentProject: ReturnType<typeof vi.fn>;
loading: boolean;
} = {
currentProject: { ...DEFAULT_PROJECT },
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", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
getViewportMode: () => mockUseViewportMode(),
isMobileViewport: () => mockUseViewportMode() === "mobile",
}));
// Mock isIOS so FN-3290 keyboard-open behavior is testable in jsdom
vi.mock("../../hooks/useMobileScrollLock", () => ({
useMobileScrollLock: vi.fn(),
useMobileKeyboardViewportLock: vi.fn(),
useMobileViewportRestoreReset: vi.fn(),
isIOS: () => true,
_resetLockState: vi.fn(),
}));
import { App, didEnterAwaitingApproval, didEnterDone } from "../../App";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews, fetchDashboardHealth, fetchBoardWorkflows } from "../../api";
import { __resetShellHostContextForTests } from "../../shell-host";
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
async function waitForAppShell(): Promise<void> {
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
expect(screen.getByTitle("Settings")).toBeTruthy();
});
}
beforeEach(() => {
vi.clearAllMocks();
/*
* FNXC:DashboardTests 2026-06-22-03:47:
* App.test.tsx runs beside other dashboard specs in the same Vitest process, so reset API mock implementations as well as call counts to prevent cross-file implementation leakage.
*/
vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
vi.mocked(updateSettings).mockResolvedValue({ ...defaultSettings });
vi.mocked(fetchGlobalSettings).mockResolvedValue({ modelOnboardingComplete: true });
vi.mocked(fetchDashboardHealth).mockResolvedValue({
status: "ok",
version: "1.0.0",
uptime: 1,
engine: { available: true },
database: {
healthy: true,
corruptionDetected: false,
corruptionErrors: [],
lastCheckedAt: null,
isRunning: false,
},
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
});
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
vi.mocked(apiNodeModule.useRemoteNodeData).mockReset();
vi.mocked(apiNodeModule.useRemoteNodeData).mockReturnValue({
projects: [],
tasks: [],
health: null,
loading: false,
error: null,
refresh: vi.fn(),
});
vi.mocked(fetchAuthStatus).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "github", name: "GitHub", authenticated: true },
],
});
vi.mocked(fetchModels).mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
vi.mocked(fetchScripts).mockResolvedValue({ build: "npm run build", test: "pnpm test" });
vi.mocked(runScript).mockResolvedValue({ sessionId: "sess-script-1", command: "echo hello" });
__resetShellHostContextForTests();
localStorage.clear();
/*
* FNXC:DashboardTests 2026-07-01-12:45:
* App-level project surface tests require a coherent local project shell at reset time. Keep the default mock project list, current project, workflow metadata response, and persisted view mode aligned so broad App.test.tsx runs do not depend on project/view state leaked from earlier tests before asserting Board/List/mobile controls.
*/
localStorage.setItem("kb-dashboard-view-mode", "project");
mockSubscribeSse.mockReset();
mockSubscribeSse.mockReturnValue(vi.fn());
mockCreateTask.mockReset();
mockUseTasks.mockReset();
mockUseTasks.mockImplementation(() => ({
tasks: [],
isStale: false,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
// Reset mock states
mockProjectsState.projects = [{ ...DEFAULT_PROJECT }];
mockProjectsState.loading = false;
mockProjectsState.error = null;
mockRefreshProjects.mockReset();
mockRefreshProjects.mockImplementation(async () => {});
mockCurrentProjectState.currentProject = { ...DEFAULT_PROJECT };
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");
mockAgentStats.todoTaskCount = 0;
mockAgentStats.idleNonEphemeralCount = 1;
});
describe("FN-4250 FileBrowserProvider coverage", () => {
it("shows engine restart instructions when health reports a UI-only dashboard", async () => {
vi.mocked(fetchDashboardHealth).mockResolvedValueOnce({
status: "ok",
version: "1.0.0",
uptime: 1,
engine: {
available: false,
},
database: {
healthy: true,
corruptionDetected: false,
corruptionErrors: [],
lastCheckedAt: null,
isRunning: false,
},
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
});
mockProjectsState.loading = false;
mockProjectsState.projects = [
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
];
mockCurrentProjectState.loading = false;
render(<App />);
expect(await screen.findByText("AI engine is not running")).toBeInTheDocument();
expect(screen.getByText("pnpm local")).toBeInTheDocument();
expect(screen.getByText("fn dashboard")).toBeInTheDocument();
});
it("does not show engine restart instructions when health reports an engine", async () => {
mockProjectsState.loading = false;
mockProjectsState.projects = [
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
];
mockCurrentProjectState.loading = false;
render(<App />);
await waitFor(() => expect(fetchDashboardHealth).toHaveBeenCalled());
expect(screen.queryByText("AI engine is not running")).not.toBeInTheDocument();
});
it("does not show engine restart instructions when an older health payload omits engine status", async () => {
vi.mocked(fetchDashboardHealth).mockResolvedValueOnce({
status: "ok",
version: "1.0.0",
uptime: 1,
database: {
healthy: true,
corruptionDetected: false,
corruptionErrors: [],
lastCheckedAt: null,
isRunning: false,
},
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
});
mockProjectsState.loading = false;
mockProjectsState.projects = [
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
];
mockCurrentProjectState.loading = false;
render(<App />);
await waitFor(() => expect(fetchDashboardHealth).toHaveBeenCalled());
expect(screen.queryByText("AI engine is not running")).not.toBeInTheDocument();
});
it("shows engine restart instructions on mobile when health reports a UI-only dashboard", async () => {
vi.mocked(fetchDashboardHealth).mockResolvedValueOnce({
status: "ok",
version: "1.0.0",
uptime: 1,
engine: {
available: false,
},
database: {
healthy: true,
corruptionDetected: false,
corruptionErrors: [],
lastCheckedAt: null,
isRunning: false,
},
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
});
mockUseViewportMode.mockReturnValue("mobile");
mockProjectsState.loading = false;
mockProjectsState.projects = [
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
];
mockCurrentProjectState.loading = false;
render(<App />);
expect(await screen.findByText("AI engine is not running")).toBeInTheDocument();
expect(screen.getByText("fn dashboard")).toBeInTheDocument();
});
it("FN-4779: renders app shell immediately when project data is ready", () => {
mockProjectsState.loading = false;
mockProjectsState.projects = [
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
];
mockCurrentProjectState.loading = false;
render(<App />);
expect(screen.getByTitle("Settings")).toBeInTheDocument();
expect(screen.queryByTestId("fb-probe-loader")).not.toBeInTheDocument();
});
it("FN-4801: keeps top progress bar visible while tasks are stale", () => {
mockProjectsState.loading = false;
mockProjectsState.projects = [
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
];
mockCurrentProjectState.loading = false;
mockUseTasks.mockImplementation(() => ({
tasks: [],
isStale: true,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
const progressBar = screen.getByRole("progressbar", { name: "Loading" });
expect(progressBar).toHaveAttribute("aria-busy", "true");
expect(progressBar).toHaveAttribute("data-visible", "true");
});
it("FN-4801: hides top progress bar on next render when tasks are fresh", () => {
mockProjectsState.loading = false;
mockProjectsState.projects = [
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
];
mockCurrentProjectState.loading = false;
const taskHookState = { isStale: true };
mockUseTasks.mockImplementation(() => ({
tasks: [],
isStale: taskHookState.isStale,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
const { rerender } = render(<App />);
const progressBar = screen.getByRole("progressbar", { name: "Loading" });
expect(progressBar).toHaveAttribute("aria-busy", "true");
expect(progressBar).toHaveAttribute("data-visible", "true");
taskHookState.isStale = false;
rerender(<App />);
expect(progressBar).toHaveAttribute("aria-busy", "false");
expect(progressBar).toHaveAttribute("data-visible", "false");
});
it("FN-4250: ChatView branch is inside FileBrowserProvider", async () => {
localStorage.setItem(taskViewStorageKey(), "chat");
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("fb-probe-chat")).toHaveTextContent("ok");
});
});
it("FN-4250: loader branch is inside FileBrowserProvider", async () => {
mockProjectsState.projects = [];
mockProjectsState.loading = true;
mockCurrentProjectState.currentProject = null;
mockCurrentProjectState.loading = true;
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("fb-probe-loader")).toHaveTextContent("ok");
});
});
});
describe("Capacity risk banner gating", () => {
it("does not render banner when capacityRiskBannerEnabled is false", async () => {
mockAgentStats.todoTaskCount = 21;
mockAgentStats.idleNonEphemeralCount = 0;
vi.mocked(fetchSettings).mockResolvedValue({
...defaultSettings,
capacityRiskBannerEnabled: false,
capacityRiskTodoThreshold: 20,
});
render(<App />);
await waitForAppShell();
expect(screen.queryByText(/Capacity risk:/i)).not.toBeInTheDocument();
});
it("renders and dismisses banner when enabled", async () => {
mockAgentStats.todoTaskCount = 21;
mockAgentStats.idleNonEphemeralCount = 0;
vi.mocked(fetchSettings).mockResolvedValue({
...defaultSettings,
capacityRiskBannerEnabled: true,
capacityRiskTodoThreshold: 20,
});
render(<App />);
await waitFor(() => {
expect(screen.getByText(/Capacity risk:/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /dismiss capacity warning/i }));
expect(localStorage.getItem(scopedKey("kb-capacity-risk-banner-dismissed", DEFAULT_PROJECT_ID))).toBe("true");
await waitFor(() => {
expect(screen.queryByText(/Capacity risk:/i)).not.toBeInTheDocument();
});
});
});
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(<App />);
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 the empty-project dashboard after connectivity recovers", async () => {
vi.useFakeTimers();
try {
mockProjectsState.projects = [];
mockProjectsState.error = "Backend unavailable";
mockCurrentProjectState.currentProject = null;
localStorage.setItem("kb-dashboard-view-mode", "overview");
mockRefreshProjects.mockImplementation(async () => {
mockProjectsState.error = null;
});
const { rerender } = render(<App />);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(screen.getByRole("button", { name: "Retry Connection" })).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Retry Connection" }));
await vi.advanceTimersByTimeAsync(1);
});
expect(mockRefreshProjects).toHaveBeenCalledTimes(1);
rerender(<App />);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
/*
* FNXC:BackendRecovery 2026-06-25-10:58:
* Recovering from an unreachable backend should resume the current no-project dashboard state, not force the setup wizard. The retry assertion pins the user-visible recovery surface after the navigation-default cleanup.
*/
expect(screen.getByText("No Projects Found")).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
});
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("didEnterDone", () => {
it("returns true only when status newly enters done", () => {
expect(didEnterDone("done", "in-progress")).toBe(true);
expect(didEnterDone("done", "todo")).toBe(true);
expect(didEnterDone("done", "done")).toBe(false);
expect(didEnterDone("in-progress", "todo")).toBe(false);
expect(didEnterDone("in-progress", undefined)).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<typeof vi.fn>).mockRejectedValueOnce(unreadFetchError);
render(<App />);
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<typeof vi.fn>)
.mockResolvedValueOnce({ unreadCount: 0 })
.mockResolvedValueOnce({ unreadCount: 4 });
render(<App />);
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalled();
});
const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["message:received"] === "function",
);
const subscriptionConfig = mailboxSubscriptionCall?.[1] as {
events: Record<string, () => 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("does not show the mailbox 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(),
refreshTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled());
const approvalSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["task:updated"] === "function",
);
const subscriptionConfig = approvalSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => 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.queryByLabelText("Approval requests")).toBeNull();
});
it("persists dismissals and suppresses repeat alerts for the same real approval request", 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(),
refreshTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
const { unmount } = render(<App />);
await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled());
const approvalSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events")
&& typeof (sub as { events?: Record<string, unknown> })?.events?.["approval:requested"] === "function"
&& typeof (sub as { events?: Record<string, unknown> })?.events?.["task:updated"] === "function",
);
const subscriptionConfig = approvalSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
subscriptionConfig.events["approval:requested"](
new MessageEvent("approval:requested", {
data: JSON.stringify({ id: "approval-1", taskId: "FN-4", updatedAt: "2026-05-05T10:00:00.000Z" }),
}),
);
});
fireEvent.click(screen.getByLabelText("Dismiss approval notification banner"));
expect(screen.queryByLabelText("Approval requests")).toBeNull();
unmount();
render(<App />);
const latestSubscription = mockSubscribeSse.mock.calls
.slice()
.reverse()
.find(([, sub]) => typeof (sub as { events?: Record<string, unknown> })?.events?.["approval:requested"] === "function"
&& typeof (sub as { events?: Record<string, unknown> })?.events?.["task:updated"] === "function");
const latestConfig = latestSubscription?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
};
await act(async () => {
latestConfig.events["approval:requested"](
new MessageEvent("approval:requested", {
data: JSON.stringify({ id: "approval-1", taskId: "FN-4", 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(),
refreshTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
await waitFor(() => expect(mockSubscribeSse).toHaveBeenCalled());
const mailboxSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["task:updated"] === "function",
);
const subscriptionConfig = mailboxSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => 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", () => {
const getChatEvents = async () => {
render(<App />);
await waitFor(() => {
expect(mockSubscribeSse).toHaveBeenCalled();
});
const chatSubscriptionCall = mockSubscribeSse.mock.calls.find(
([url, sub]) => String(url).startsWith("/api/events") && typeof (sub as { events?: Record<string, unknown> })?.events?.["chat:message:added"] === "function",
);
return (chatSubscriptionCall?.[1] as {
events: Record<string, (event: MessageEvent) => void>;
}).events;
};
// FNXC:Navigation 2026-06-22-09:30: With the left sidebar as primary nav, the chat
// unread indicator moved from the header chat button to the Chat sidebar entry's
// status dot (.left-sidebar-nav__dot inside the sidebar-nav-chat button).
const chatUnreadDot = () => {
const chatNav = screen.queryByTestId("sidebar-nav-chat");
return chatNav ? chatNav.querySelector(".left-sidebar-nav__dot.status-dot--pending") : null;
};
it("shows unread indicator when assistant message arrives for any individual session", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-other" }),
}),
);
});
await waitFor(() => {
expect(chatUnreadDot()).not.toBeNull();
});
});
it("does not show unread indicator for hidden planner assistant messages", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-planner", agentId: "task-planner:FN-7392" }),
}),
);
});
const chatNav = screen.getByTestId("sidebar-nav-chat");
expect(chatNav).toBeInTheDocument();
expect(chatUnreadDot()).toBeNull();
expect(chatNav.querySelector(".left-sidebar-nav__dot")).toBeNull();
});
it("does not show mobile unread indicator for hidden planner assistant messages", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const events = await getChatEvents();
await act(async () => {
events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "assistant", sessionId: "sess-planner", agentId: "task-planner:FN-7392" }),
}),
);
});
const mobileChatNav = screen.getByTestId("mobile-nav-tab-chat");
expect(mobileChatNav).toBeInTheDocument();
expect(mobileChatNav.querySelector(".mobile-nav-chat-unread-dot")).toBeNull();
});
it("shows unread indicator for planner assistant messages visible in the common Chat feed", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({
role: "assistant",
sessionId: "sess-planner",
agentId: "task-planner:FN-7392",
taskChatVisibleInCommonFeed: true,
}),
}),
);
});
await waitFor(() => {
expect(chatUnreadDot()).not.toBeNull();
});
});
it("does not show unread indicator for individual user messages", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:message:added"](
new MessageEvent("chat:message:added", {
data: JSON.stringify({ role: "user", sessionId: "sess-active" }),
}),
);
});
expect(chatUnreadDot()).toBeNull();
});
it("shows unread indicator for room assistant replies", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:room:message:added"](
new MessageEvent("chat:room:message:added", {
data: JSON.stringify({ role: "assistant", roomId: "room-1", id: "msg-1", content: "hi", createdAt: new Date().toISOString() }),
}),
);
});
await waitFor(() => {
expect(chatUnreadDot()).not.toBeNull();
});
});
it("does not show unread indicator for room user messages", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:room:message:added"](
new MessageEvent("chat:room:message:added", {
data: JSON.stringify({ role: "user", roomId: "room-1", id: "msg-2", content: "mine", createdAt: new Date().toISOString() }),
}),
);
});
expect(chatUnreadDot()).toBeNull();
});
it("clears unread indicator when returning to chat and does not mark while in chat", async () => {
const events = await getChatEvents();
await act(async () => {
events["chat:room:message:added"](
new MessageEvent("chat:room:message:added", {
data: JSON.stringify({ role: "assistant", roomId: "room-1", id: "msg-3", content: "reply", createdAt: new Date().toISOString() }),
}),
);
});
await waitFor(() => {
expect(chatUnreadDot()).not.toBeNull();
});
fireEvent.click(screen.getByTestId("sidebar-nav-chat"));
await waitFor(() => {
expect(chatUnreadDot()).toBeNull();
});
await act(async () => {
events["chat:room:message:added"](
new MessageEvent("chat:room:message:added", {
data: JSON.stringify({ role: "assistant", roomId: "room-1", id: "msg-4", content: "while-open", createdAt: new Date().toISOString() }),
}),
);
});
expect(chatUnreadDot()).toBeNull();
});
});
describe("App deep link handling", () => {
const originalLocation = window.location;
const originalReplaceState = window.history.replaceState;
beforeEach(() => {
/*
* FNXC:DashboardTests 2026-06-26-23:40:
* Deep-link App tests stub `history.replaceState` for assertions, but `useNavigationHistory` still writes real `pushState` entries and preserves existing `history.state.navIndex`. Reset the real history snapshot before installing the stub so full-file ordering cannot mount a later Back test with stale browser state.
*/
originalReplaceState.call(window.history, null, "", "/");
window.history.replaceState = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/"),
});
});
afterEach(() => {
window.history.replaceState = originalReplaceState;
originalReplaceState.call(window.history, null, "", "/");
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
});
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(<App />);
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<typeof vi.fn>).mockRejectedValueOnce(new Error("Not found"));
render(<App />);
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(<App />);
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(<App />);
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 () => {
/*
* FNXC:DeepLink 2026-07-03-09:50:
* The not-found toast is deferred behind a grace window so an onboarding project deep-linked before
* its list revalidates does not flash a spurious error. A genuinely nonexistent project stays absent
* past the window and still surfaces the toast — advance fake timers to reach it.
*/
vi.useFakeTimers();
try {
mockProjectsState.projects = [];
mockCurrentProjectState.currentProject = null;
Object.defineProperty(window, "location", {
configurable: true,
value: new URL("http://localhost:3000/?project=nonexistent&task=FN-123"),
});
render(<App />);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(fetchSettings).toHaveBeenCalled();
// Within the grace window nothing has toasted yet.
expect(screen.queryByText("Project 'nonexistent' not found")).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
// Grace window elapsed with the project still absent — the error toast now shows.
expect(screen.getByText("Project 'nonexistent' not found")).toBeTruthy();
// Should NOT fetch the task since project wasn't found.
expect(fetchTaskDetail).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
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(<App />);
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(<App />);
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(<App />);
// 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(<App />);
// 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(<App />);
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(<App />);
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(<App />);
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(<App />);
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("closes board-opened main-panel task detail on one browser back", async () => {
const boardTask = { id: "FN-6964", title: "Back nav task", description: "x", status: "todo", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
mockUseTasks.mockImplementation(() => ({
tasks: [boardTask],
isStale: false,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByText("Back nav task"));
expect(await screen.findByTestId("main-panel-task-detail")).toBeTruthy();
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await waitFor(() => {
expect(screen.queryByTestId("main-panel-task-detail")).toBeNull();
expect(screen.getByText("Back nav task")).toBeTruthy();
});
});
it("restores the previous main-panel task detail on nested detail browser back", async () => {
const boardTask = { id: "FN-6964", title: "Back nav task", description: "x", status: "todo", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
mockUseTasks.mockImplementation(() => ({
tasks: [boardTask],
isStale: false,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByText("Back nav task"));
expect(await screen.findByText("Open nested task")).toBeTruthy();
fireEvent.click(screen.getByText("Open nested task"));
expect(await screen.findByText("Nested task")).toBeTruthy();
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 1 } }));
});
await waitFor(() => {
expect(screen.queryByText("Nested task")).toBeNull();
expect(screen.getByTestId("main-panel-task-detail")).toBeTruthy();
expect(screen.getByText("Back nav task")).toBeTruthy();
});
});
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(<App />);
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(<App />);
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(<App />);
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-missions")).toBeTruthy();
});
});
});
describe("App auto-open Settings on unauthenticated", () => {
beforeEach(() => {
vi.mocked(fetchAuthStatus).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false },
{ id: "github", name: "GitHub", authenticated: false },
],
});
});
it("auto-opens onboarding modal when all providers are unauthenticated and onboarding not complete", async () => {
vi.mocked(fetchGlobalSettings).mockResolvedValue({});
render(<App />);
// 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.queryByRole("heading", { name: "Settings" })).toBeNull();
});
it("auto-opens Settings to Authentication tab when all providers are unauthenticated but onboarding IS complete", async () => {
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: true,
});
render(<App />);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// The Settings modal should be open showing Authentication content.
// App and SettingsModal both hydrate settings, and follow-up refreshes may
// legitimately add another fetch during initialization; the invariant here
// is that settings hydration happened before Authentication content renders.
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Authentication section should be active — auth status is fetched when section is active
expect(await screen.findByText("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<typeof vi.fn>).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "github", name: "GitHub", authenticated: false },
],
});
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
render(<App />);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// Settings modal should NOT be open
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.queryByRole("heading", { name: "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<typeof vi.fn>).mockResolvedValue({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" },
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
],
});
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "openrouter",
defaultModelId: "gpt-4o",
});
render(<App />);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.queryByRole("heading", { name: "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<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
],
});
// No defaultProvider or defaultModelId → setup incomplete
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: false,
});
render(<App />);
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<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
render(<App />);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Settings modal should NOT be open
expect(screen.queryByRole("heading", { name: "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 () => {
vi.mocked(fetchGlobalSettings).mockResolvedValue({});
render(<App />);
// 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 sidebar Settings entry (header gear is hidden when the
// left sidebar owns desktop Settings); it navigates to the embedded SettingsView.
const settingsButton = screen.getByTitle("Settings");
fireEvent.click(settingsButton);
// Settings should open with Authentication section (first/default)
await waitFor(() => expect(fetchSettings.mock.calls.length).toBeGreaterThanOrEqual(2));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// Authentication section content should be visible (providers listed).
// The embedded SettingsView is lazy + fetches auth async, so await the provider row.
await waitFor(() => {
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(<App />);
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 () => {
vi.mocked(fetchGlobalSettings).mockResolvedValue({});
vi.mocked(fetchAuthStatus).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false },
{ id: "github", name: "GitHub", authenticated: false },
],
});
// Set up localStorage with resumable state
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ currentStep: "ai-setup", updatedAt: new Date().toISOString() })
);
render(<App />);
// 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(<App />);
await waitForAppShell();
// Resume card should NOT be visible (onboarding is complete)
expect(screen.queryByText("Continue Setup")).toBeNull();
});
});
describe("App view switching", () => {
// FNXC:Navigation 2026-06-22-09:30: Research/Evals/Insights/Memory are now left-sidebar
// destinations (sidebar-nav-*), not header More-views overflow items, on desktop.
it("opens research view from the sidebar and persists view selection", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
researchView: true,
},
});
render(<App />);
fireEvent.click(await screen.findByTestId("sidebar-nav-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 the sidebar and persists view selection", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
render(<App />);
fireEvent.click(await screen.findByTestId("sidebar-nav-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<typeof vi.fn>).mockResolvedValue({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
researchView: false,
},
});
render(<App />);
// Wait for the sidebar to render, then assert Research is not a destination.
await screen.findByTestId("sidebar-nav-board");
expect(screen.queryByTestId("sidebar-nav-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<typeof vi.fn>).mockResolvedValue({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
researchView: true,
},
});
render(<App />);
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<typeof vi.fn>).mockResolvedValue({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
researchView: false,
},
});
render(<App />);
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<typeof vi.fn>).mockResolvedValue({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
evalsView: false,
},
});
render(<App />);
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(<App />);
// 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(<App />);
// Wait for the header to render with view toggle
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
});
// Click to switch to list view
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
// 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(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
});
// Switch to list view
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
await waitFor(() => {
expect(document.querySelector(".list-view")).toBeTruthy();
});
// Switch back to board view
fireEvent.click(screen.getByTestId("sidebar-nav-board"));
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(<App />);
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
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.
// Scope the title to the modal heading; the left sidebar also renders a "New Task" nav label.
await waitFor(() => {
expect(screen.getByRole("heading", { name: "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(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
});
// Switch to list view
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
// 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(<App />);
// Wait for the app to render
await waitFor(() => {
expect(document.querySelector(".list-view")).toBeTruthy();
});
// List view should be active
expect(screen.getByTestId("sidebar-nav-list").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<typeof vi.fn>).mockResolvedValueOnce([
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" },
},
]);
render(<App />);
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<typeof vi.fn>).mockResolvedValueOnce([]);
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("dependency-graph")).toBeInTheDocument();
});
localStorage.removeItem(taskViewStorageKey());
localStorage.removeItem("kb-dashboard-view-mode");
});
it("hides the removed Roadmaps destination even when settings and plugin API still mention it", async () => {
/*
FNXC:RoadmapsNavigation 2026-06-22-18:50:
Roadmaps was removed as an app view and experiment. Stale persisted flags and plugin dashboard rows must not expose the old sidebar destination.
*/
mockUseViewportMode.mockReturnValue("desktop");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, roadmap: true },
});
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
pluginId: "fusion-plugin-roadmap",
view: {
viewId: "roadmaps",
label: "Roadmaps",
componentPath: "./dashboard-view",
icon: "Map",
placement: "primary",
order: 30,
},
},
]);
render(<App />);
expect(await screen.findByTestId("sidebar-nav-missions")).toBeInTheDocument();
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeNull();
});
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<typeof vi.fn>).mockResolvedValueOnce([
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" },
},
]);
const first = render(<App />);
await waitFor(() => {
expect(screen.getByTestId("dependency-graph")).toBeInTheDocument();
});
first.unmount();
localStorage.setItem(taskViewStorageKey(), "board");
const second = render(<App />);
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-board").className).toContain("active");
});
second.unmount();
localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph");
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" },
},
]);
render(<App />);
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<typeof vi.fn>).mockResolvedValue({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
todoView: true,
},
});
localStorage.setItem(taskViewStorageKey(), "todos");
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("todo-planning-button"));
await waitFor(() => {
expect(screen.getByTestId("planning-view")).toBeInTheDocument();
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(<App />);
// Wait for the header to render with view toggle
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-agents")).toBeTruthy();
});
});
it("hides agent view controls when no project is active", async () => {
mockCurrentProjectState.currentProject = null;
localStorage.setItem("kb-dashboard-view-mode", "overview");
render(<App />);
await waitFor(() => {
expect(screen.queryByTestId("sidebar-nav-agents")).toBeNull();
});
localStorage.removeItem("kb-dashboard-view-mode");
});
it("renders AgentsView when agents view is selected", async () => {
render(<App />);
const agentsViewButton = await screen.findByTestId("sidebar-nav-agents", {}, { 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(<App />);
const agentsViewButton = await screen.findByTestId("sidebar-nav-agents", {}, { 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(<App />);
await waitFor(() => {
expect(document.querySelector(".agents-view")).toBeTruthy();
});
expect(screen.getByTestId("sidebar-nav-agents").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: { ...defaultSettings.experimentalFeatures, insights: true, skillsView: true }, // no agentsView
});
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
});
expect(screen.getByTestId("sidebar-nav-agents")).toBeTruthy();
// Cleanup: restore default mock
vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
});
// ── Insights View ──────────────────────────────────────────────────
it("renders InsightsView when insights view is selected", async () => {
render(<App />);
/*
* FNXC:Navigation 2026-06-22-09:30:
* Insights is now a left-sidebar destination (sidebar-nav-insights), not a header
* More-views overflow command. Navigate via the sidebar to exercise lazy-view routing.
*/
fireEvent.click(await screen.findByTestId("sidebar-nav-insights"));
// Insights view should be rendered (it has a insights-view container)
expect(await screen.findByTestId("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(<App />);
fireEvent.click(await screen.findByTestId("sidebar-nav-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(<App />);
fireEvent.click(await screen.findByTestId("sidebar-nav-insights"));
await waitFor(() => {
expect(localStorage.getItem(taskViewStorageKey())).toBe("insights");
});
});
it("initializes insights view from localStorage if saved", async () => {
localStorage.setItem(taskViewStorageKey(), "insights");
render(<App />);
await waitFor(() => {
expect(document.querySelector(".insights-view")).toBeTruthy();
});
// Sidebar Insights entry should be active when view is insights
expect(screen.getByTestId("sidebar-nav-insights").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(<App />);
// Wait for project A's insights view to load
await waitFor(() => {
expect(document.querySelector(".insights-view")).toBeTruthy();
});
// Verify the sidebar Insights entry is active
expect(screen.getByTestId("sidebar-nav-insights").className).toContain("active");
// Cleanup
localStorage.removeItem("kb:proj_a:kb-dashboard-task-view");
localStorage.removeItem("kb:proj_b:kb-dashboard-task-view");
});
it("keeps insights view button visible after graduation from experimental flags", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, insights: false },
});
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
});
// FNXC:DefaultNavigation 2026-06-23-01:24: Insights graduated from experimental navigation; stale false flags must not remove the destination.
expect(screen.getByTestId("sidebar-nav-insights")).toBeTruthy();
});
it("keeps graduated views available after settings load with no experimental flags", 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(<App />);
expect(screen.queryByTitle("Board view")).toBeNull();
expect(document.querySelector(".insights-view")).toBeNull();
expect(document.querySelector(".board")).toBeNull();
resolveSettings?.({
...defaultSettings,
experimentalFeatures: { leftSidebarNav: false },
});
await waitFor(() => {
expect(document.querySelector(".insights-view")).toBeTruthy();
});
expect(document.querySelector(".board")).toBeNull();
localStorage.removeItem(taskViewStorageKey());
});
it("keeps memory view button visible after graduation from experimental flags", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, memoryView: false, insights: true },
});
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
});
expect(screen.getByTestId("sidebar-nav-memory")).toBeTruthy();
});
it("keeps memory view selected after graduation from experimental flags", async () => {
localStorage.setItem(taskViewStorageKey(), "memory");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, memoryView: false },
});
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
await waitFor(() => {
expect(document.querySelector(".memory-view")).toBeTruthy();
});
expect(document.querySelector(".board")).toBeNull();
localStorage.removeItem(taskViewStorageKey());
});
it("renders goals view when goalsView experimental feature is enabled", async () => {
localStorage.setItem(taskViewStorageKey(), "goalsView");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, goalsView: true },
});
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("goals-view")).toBeTruthy();
});
localStorage.removeItem(taskViewStorageKey());
});
it("keeps goals view selected after graduation from experimental flags", async () => {
localStorage.setItem(taskViewStorageKey(), "goalsView");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, goalsView: false },
});
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByTestId("goals-view")).toBeTruthy();
});
expect(screen.getByTestId("sidebar-nav-goals")).toBeTruthy();
expect(document.querySelector(".board")).toBeNull();
localStorage.removeItem(taskViewStorageKey());
});
it("keeps todo view selected after graduation from experimental flags", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(taskViewStorageKey(), "todos");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, todoView: false },
});
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByTestId("todo-view")).toBeTruthy();
});
/*
* FNXC:DefaultNavigation 2026-06-25-11:03:
* Todo graduated with the other primary destinations; stale `todoView:false` settings must not redirect a saved Todo view back to Board. Todos intentionally stay out of the left sidebar because that destination lives in the right dock and mobile More sheet.
*/
expect(document.querySelector(".board")).toBeNull();
localStorage.removeItem(taskViewStorageKey());
localStorage.removeItem("kb-dashboard-view-mode");
});
});
describe("App GitHub import", () => {
// FNXC:Navigation 2026-06-22-09:30: GitHub import is now the left-sidebar "Import Tasks"
// destination rendering the GitHubImportModal embedded in main content (presentation="embedded"),
// not a header-button modal overlay. Navigation in/out goes through the sidebar; embedded mode
// has no overlay or Cancel affordance (closing returns to the board view).
it("opens GitHub import as an embedded view from the Import Tasks sidebar destination", async () => {
render(<App />);
const importNavItem = await screen.findByTestId("sidebar-nav-import-tasks");
fireEvent.click(importNavItem);
await waitFor(() => {
expect(screen.getByTestId("github-import-view")).toBeTruthy();
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
});
it("closes the embedded GitHub import view back to the board", async () => {
render(<App />);
fireEvent.click(await screen.findByTestId("sidebar-nav-import-tasks"));
await waitFor(() => {
expect(screen.getByTestId("github-import-view")).toBeTruthy();
});
// Returning to the board is the embedded close path (no overlay/Cancel button).
fireEvent.click(await screen.findByTestId("sidebar-nav-board"));
await waitFor(() => {
expect(screen.queryByTestId("github-import-view")).toBeNull();
expect(document.querySelector(".board")).toBeTruthy();
});
});
});
describe("App Planning Mode", () => {
it("opens Planning Mode as an embedded view from the sidebar destination", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
vi.mocked(fetchSettings).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
});
render(<App />);
const planningNavItem = await screen.findByTestId("sidebar-nav-planning");
fireEvent.click(planningNavItem);
await waitFor(() => {
expect(screen.getByTestId("planning-view")).toBeTruthy();
expect(screen.getByText("Planning Mode")).toBeTruthy();
});
expect(screen.queryByTestId("planning-btn")).toBeNull();
});
it("closes Planning Mode embedded view back to the board", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
vi.mocked(fetchSettings).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
});
render(<App />);
fireEvent.click(await screen.findByTestId("sidebar-nav-planning"));
await waitFor(() => {
expect(screen.getByTestId("planning-view")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("Close"));
await waitFor(() => {
expect(screen.queryByText("Transform your idea into a detailed task")).toBeNull();
expect(screen.getByTestId("sidebar-nav-board").getAttribute("aria-current")).toBe("page");
});
});
it("renders planning embedded view with correct initial state", async () => {
localStorage.setItem(taskViewStorageKey(), "planning");
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("planning-view")).toBeTruthy();
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();
});
localStorage.removeItem(taskViewStorageKey());
});
});
describe("Script run flow", () => {
it("calls runScript API and returns session info", async () => {
render(<App />);
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<typeof vi.fn>).mockRejectedValueOnce(new Error("Script not found"));
render(<App />);
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", () => {
beforeEach(() => {
mockProjectsState.projects = [mockCurrentProjectState.currentProject!];
});
it("closes ScriptsModal and opens TerminalModal when Run is clicked", async () => {
render(<App />);
// 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 = await screen.findByTestId("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(<App />);
await waitFor(() => {
expect(screen.getByTitle("Settings")).toBeTruthy();
});
// Open the Scripts modal and run a script
const scriptsBtn = await screen.findByTestId("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(<App />);
await waitFor(() => {
expect(screen.getByTitle("Settings")).toBeTruthy();
});
// Open the Scripts modal
const scriptsBtn = await screen.findByTestId("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(<App />);
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(<App />);
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(<App />);
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(<App />);
await waitFor(() => {
expect(document.querySelector(".project-content--with-footer")).toBeTruthy();
});
// Switch to overview by clearing project
mockCurrentProjectState.currentProject = null;
mockProjectsState.projects = [];
rerender(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
// 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(<App />);
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(<App />);
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(<App />);
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(<App />);
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<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
render(<App />);
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<typeof vi.fn>)
.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<typeof vi.fn>).mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
});
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
],
});
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
});
render(<App />);
await waitForAppShell();
// Onboarding should NOT be open initially
expect(screen.queryByText("Set Up AI")).toBeNull();
// Open Settings via header (quick-entry controls also mention Settings in labels)
const settingsBtn = screen.getByTitle("Settings");
fireEvent.click(settingsBtn);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "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<typeof vi.fn>).mockResolvedValue({
modelOnboardingComplete: true,
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
// Mock Settings and auth
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
});
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
],
});
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: false, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
});
render(<App />);
await waitForAppShell();
// Open Settings via header (quick-entry controls also mention Settings in labels)
const settingsBtn = screen.getByTitle("Settings");
fireEvent.click(settingsBtn);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "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(<App />);
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<typeof vi.fn>).mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
],
});
(fetchGlobalSettings as ReturnType<typeof vi.fn>).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(<App />);
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(<App />);
// 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(<App />);
// 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(<App />);
// 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(),
refreshTasks: vi.fn(),
}));
render(<App />);
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(),
refreshTasks: vi.fn(),
}));
render(<App />);
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(),
refreshTasks: vi.fn(),
}));
render(<App />);
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(<App />);
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(),
refreshTasks: vi.fn(),
}));
render(<App />);
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(),
refreshTasks: vi.fn(),
}));
const { rerender } = render(<App />);
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(<App />);
await waitForAppShell();
/*
* FNXC:BoardSearch 2026-07-04-13:30: The non-mobile search panel intentionally stays open
* across a same-instance rerender (Header's isNonMobileSearchOpen state is not project-scoped),
* so the toggle button correctly unmounts once the panel is open (see canShowNonMobileSearchToggle
* in Header.tsx) and there is no button left to re-click here. The branch-filter selects remain
* rendered while the panel is open, so read them directly instead of re-clicking the toggle.
*/
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 () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(taskViewStorageKey(), "board");
vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
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(),
refreshTasks: vi.fn(),
}));
render(<App />);
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.getByTestId("sidebar-nav-list"));
await waitFor(() => {
expect(screen.getByText("Alpha Search")).toBeTruthy();
expect(screen.getByText("Beta Search")).toBeTruthy();
});
});
});
describe("FN-5817 mobile auto-merge toggle stability", () => {
it("keeps app shell mounted when toggling auto-merge on mobile", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const updateSettingsSpy = vi.mocked(updateSettings);
updateSettingsSpy.mockResolvedValue({ ...defaultSettings, autoMerge: false });
mockUseTasks.mockImplementation(() => ({
tasks: [
{
id: "FN-5817",
title: "In review task",
description: "Regression task",
column: "in-review",
status: "in-review",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "",
updatedAt: "",
},
],
isStale: false,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(<App />);
const toggle = await screen.findByRole("checkbox", { name: "Auto-merge" });
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
expect(document.querySelector("main.board")).not.toBeNull();
expect(screen.getByText("In review task")).toBeInTheDocument();
expect(screen.queryByText("Something went wrong")).toBeNull();
fireEvent.click(toggle);
await waitFor(() => {
expect(updateSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({ autoMerge: expect.any(Boolean) }),
DEFAULT_PROJECT_ID,
);
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Auto-merge" })).toBeInTheDocument();
expect(document.querySelector("main.board")).not.toBeNull();
expect(screen.getByText("In review task")).toBeInTheDocument();
});
expect(screen.queryByText("Something went wrong")).toBeNull();
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
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(<App />);
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(<App />);
await waitFor(() => {
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
});
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
});
it("keeps shell connection status out of compact mobile header chrome", async () => {
mockUseViewportMode.mockReturnValue("mobile");
localStorage.setItem("kb-dashboard-view-mode", "project");
vi.mocked(fetchSettings).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
});
vi.mocked(fetchGlobalSettings).mockResolvedValueOnce({ modelOnboardingComplete: true });
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(<App />);
await waitFor(() => {
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
});
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull();
});
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(<App />);
await waitFor(() => {
expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1);
});
expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull();
});
});