4188 lines
136 KiB
TypeScript
4188 lines
136 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,
|
|
experimentalFeatures: { insights: true, skillsView: true, agentsView: true, memoryView: true, evalsView: 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,
|
|
database: { healthy: true, lastCheckedAt: null, isRunning: false },
|
|
taskIdIntegrity: { status: "ok", checkedAt: "2026-05-12T00:00:00.000Z", anomalies: [], recommendedAction: null },
|
|
})),
|
|
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
|
|
fetchExecutorStats: vi.fn(() => Promise.resolve({
|
|
globalPause: false,
|
|
enginePaused: false,
|
|
maxConcurrent: 2,
|
|
lastActivityAt: new Date().toISOString(),
|
|
})),
|
|
fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })),
|
|
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-script-1", command: "echo hello" })),
|
|
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
|
|
});
|
|
});
|
|
|
|
const mockCreateTask = vi.fn();
|
|
|
|
const mockUseTasks = vi.fn(() => ({
|
|
tasks: [],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
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,
|
|
localServer: null,
|
|
};
|
|
|
|
const mockGetShellConnectionNativeResult = vi.fn(async () => ({
|
|
hostKind: "browser" as const,
|
|
available: false,
|
|
openConnectionManager: async () => ({ ok: false as const, reason: "unsupported" as const }),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useShellConnection", () => ({
|
|
useShellConnection: vi.fn(() => ({
|
|
shellApi: null,
|
|
state: mockShellConnectionState,
|
|
ready: true,
|
|
openConnectionManagerSignal: 0,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../../shell-native", () => ({
|
|
getShellConnectionNativeResult: (...args: unknown[]) => mockGetShellConnectionNativeResult(...args),
|
|
}));
|
|
|
|
// Mock model-onboarding-state
|
|
const mockIsOnboardingResumable = vi.fn();
|
|
const mockGetOnboardingResumeStep = vi.fn();
|
|
const mockGetOnboardingState = vi.fn();
|
|
const mockSaveOnboardingState = vi.fn();
|
|
const mockClearOnboardingState = vi.fn();
|
|
const mockIsOnboardingCompleted = vi.fn();
|
|
const mockMarkOnboardingCompleted = vi.fn();
|
|
const mockMarkStepSkipped = vi.fn();
|
|
const mockGetOnboardingCompletedAt = vi.fn();
|
|
const mockGetSkippedSteps = vi.fn();
|
|
const mockGetStepData = vi.fn();
|
|
|
|
vi.mock("../../components/model-onboarding-state", () => ({
|
|
isOnboardingResumable: (...args: unknown[]) => mockIsOnboardingResumable(...args),
|
|
getOnboardingResumeStep: (...args: unknown[]) => mockGetOnboardingResumeStep(...args),
|
|
getOnboardingState: (...args: unknown[]) => mockGetOnboardingState(...args),
|
|
saveOnboardingState: (...args: unknown[]) => mockSaveOnboardingState(...args),
|
|
clearOnboardingState: (...args: unknown[]) => mockClearOnboardingState(...args),
|
|
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
|
markOnboardingCompleted: (...args: unknown[]) => mockMarkOnboardingCompleted(...args),
|
|
markStepSkipped: (...args: unknown[]) => mockMarkStepSkipped(...args),
|
|
getOnboardingCompletedAt: (...args: unknown[]) => mockGetOnboardingCompletedAt(...args),
|
|
getSkippedSteps: (...args: unknown[]) => mockGetSkippedSteps(...args),
|
|
getStepData: (...args: unknown[]) => mockGetStepData(...args),
|
|
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
|
}));
|
|
|
|
// Mock CustomModelDropdown for onboarding modal tests
|
|
vi.mock("../../components/CustomModelDropdown", () => ({
|
|
CustomModelDropdown: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
|
|
<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>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../../components/GitHubImportModal", () => ({
|
|
GitHubImportModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
|
|
isOpen ? (
|
|
<div className="modal-overlay open">
|
|
<h2>Import from GitHub</h2>
|
|
<button type="button" onClick={onClose}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("../../components/PlanningModeModal", () => ({
|
|
PlanningModeModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
|
|
isOpen ? (
|
|
<div className="modal-overlay open">
|
|
<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>
|
|
);
|
|
}
|
|
|
|
return { SettingsModal: MockSettingsModal };
|
|
});
|
|
|
|
vi.mock("../../components/ModelOnboardingModal", async () => {
|
|
const React = await import("react");
|
|
const api = await import("../../api");
|
|
|
|
function MockModelOnboardingModal({
|
|
onComplete,
|
|
}: {
|
|
onComplete: () => void;
|
|
}) {
|
|
const [value, setValue] = React.useState("");
|
|
|
|
React.useEffect(() => {
|
|
void Promise.all([api.fetchGlobalSettings(), api.fetchModels()]).then(([settings]) => {
|
|
if (settings.defaultProvider && settings.defaultModelId) {
|
|
setValue(`${settings.defaultProvider}/${settings.defaultModelId}`);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<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 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: { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
|
setCurrentProject: vi.fn(),
|
|
clearCurrentProject: vi.fn(),
|
|
loading: false,
|
|
};
|
|
|
|
vi.mock("../../hooks/useProjects", () => ({
|
|
useProjects: () => ({
|
|
projects: mockProjectsState.projects,
|
|
loading: mockProjectsState.loading,
|
|
error: mockProjectsState.error,
|
|
refresh: mockRefreshProjects,
|
|
register: vi.fn(),
|
|
update: vi.fn(),
|
|
unregister: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useCurrentProject", () => ({
|
|
useCurrentProject: () => mockCurrentProjectState,
|
|
}));
|
|
|
|
// Mock useTerminal for terminal components
|
|
vi.mock("../../hooks/useTerminal", () => ({
|
|
useTerminal: () => ({
|
|
connectionStatus: "connected",
|
|
sendInput: vi.fn(),
|
|
resize: vi.fn(),
|
|
onData: vi.fn(() => vi.fn()),
|
|
onExit: vi.fn(() => vi.fn()),
|
|
onConnect: vi.fn(() => vi.fn()),
|
|
onScrollback: vi.fn(() => vi.fn()),
|
|
reconnect: vi.fn(),
|
|
onSessionInvalid: vi.fn(() => vi.fn()),
|
|
}),
|
|
}));
|
|
|
|
// Mock useNodes for node selector
|
|
vi.mock("../../hooks/useNodes", () => ({
|
|
useNodes: vi.fn(() => ({
|
|
nodes: [],
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn(),
|
|
register: vi.fn(),
|
|
update: vi.fn(),
|
|
unregister: vi.fn(),
|
|
healthCheck: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
// Mock useMobileKeyboard for modal keyboard isolation tests (FN-3290).
|
|
// Default: keyboard closed, matching real test-environment behavior.
|
|
const mockUseMobileKeyboard = vi.fn(() => ({
|
|
keyboardOverlap: 0,
|
|
viewportHeight: null,
|
|
viewportOffsetTop: 0,
|
|
keyboardOpen: false,
|
|
}));
|
|
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
|
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
|
|
}));
|
|
|
|
// Mock useViewportMode so tests can simulate mobile viewport without
|
|
// depending on window.matchMedia in jsdom.
|
|
const mockUseViewportMode = vi.fn(() => "desktop");
|
|
vi.mock("../../hooks/useViewportMode", () => ({
|
|
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
|
|
getViewportMode: () => "desktop",
|
|
}));
|
|
|
|
import { App, didEnterAwaitingApproval } from "../../App";
|
|
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
|
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
|
|
import { __resetShellHostContextForTests } from "../../shell-host";
|
|
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
|
|
|
|
async function waitForAppShell(): Promise<void> {
|
|
await waitFor(() => {
|
|
expect(fetchSettings).toHaveBeenCalled();
|
|
expect(screen.getByTitle("Settings")).toBeTruthy();
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
__resetShellHostContextForTests();
|
|
localStorage.clear();
|
|
mockSubscribeSse.mockReset();
|
|
mockSubscribeSse.mockReturnValue(vi.fn());
|
|
mockCreateTask.mockReset();
|
|
mockUseTasks.mockReset();
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [],
|
|
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 = [];
|
|
mockProjectsState.loading = false;
|
|
mockProjectsState.error = null;
|
|
mockRefreshProjects.mockReset();
|
|
mockRefreshProjects.mockImplementation(async () => {});
|
|
mockCurrentProjectState.currentProject = { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
|
mockCurrentProjectState.loading = false;
|
|
mockCurrentProjectState.setCurrentProject.mockClear();
|
|
mockCurrentProjectState.clearCurrentProject.mockClear();
|
|
// Reset node context mocks
|
|
mockNodeContextValue.currentNode = null;
|
|
mockNodeContextValue.currentNodeId = null;
|
|
mockNodeContextValue.isRemote = false;
|
|
mockNodeContextValue.setCurrentNode.mockClear();
|
|
mockNodeContextValue.clearCurrentNode.mockClear();
|
|
// Clear node selection from localStorage to avoid cross-test leakage
|
|
localStorage.removeItem("fusion-dashboard-current-node");
|
|
// Clear onboarding/chat state from localStorage
|
|
localStorage.removeItem("kb-onboarding-state");
|
|
localStorage.removeItem(scopedKey("kb-chat-active-session", "proj_123"));
|
|
// Reset onboarding state mocks
|
|
mockIsOnboardingResumable.mockReset();
|
|
mockIsOnboardingResumable.mockReturnValue(false);
|
|
mockGetOnboardingResumeStep.mockReset();
|
|
mockGetOnboardingResumeStep.mockReturnValue(null);
|
|
mockGetOnboardingState.mockReset();
|
|
mockGetOnboardingState.mockReturnValue(null);
|
|
mockSaveOnboardingState.mockReset();
|
|
mockClearOnboardingState.mockReset();
|
|
mockIsOnboardingCompleted.mockReset();
|
|
mockIsOnboardingCompleted.mockReturnValue(false);
|
|
mockMarkOnboardingCompleted.mockReset();
|
|
mockMarkStepSkipped.mockReset();
|
|
mockGetOnboardingCompletedAt.mockReset();
|
|
mockGetOnboardingCompletedAt.mockReturnValue(null);
|
|
mockGetSkippedSteps.mockReset();
|
|
mockGetSkippedSteps.mockReturnValue([]);
|
|
mockGetStepData.mockReset();
|
|
mockGetStepData.mockReturnValue(null);
|
|
mockUseInsights.mockReset();
|
|
mockUseInsights.mockImplementation(() => ({
|
|
sections: [],
|
|
loading: false,
|
|
error: null,
|
|
latestRun: null,
|
|
isRunInFlight: false,
|
|
runError: null,
|
|
refresh: vi.fn(),
|
|
runInsights: vi.fn(),
|
|
dismiss: vi.fn(),
|
|
createTask: vi.fn(),
|
|
dismissStates: new Map(),
|
|
createTaskStates: new Map(),
|
|
totalCount: 0,
|
|
dismissedCount: 0,
|
|
}));
|
|
// Reset mobile keyboard and viewport mocks to defaults (desktop, no keyboard)
|
|
mockUseMobileKeyboard.mockReset();
|
|
mockUseMobileKeyboard.mockReturnValue({
|
|
keyboardOverlap: 0,
|
|
viewportHeight: null,
|
|
viewportOffsetTop: 0,
|
|
keyboardOpen: false,
|
|
});
|
|
mockUseViewportMode.mockReset();
|
|
mockUseViewportMode.mockReturnValue("desktop");
|
|
mockAgentStats.todoTaskCount = 0;
|
|
mockAgentStats.idleNonEphemeralCount = 1;
|
|
});
|
|
|
|
describe("FN-4250 FileBrowserProvider coverage", () => {
|
|
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.loading = true;
|
|
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).mockResolvedValueOnce({
|
|
...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).mockResolvedValueOnce({
|
|
...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 setup wizard flow after connectivity recovers", async () => {
|
|
mockProjectsState.projects = [];
|
|
mockProjectsState.error = "Backend unavailable";
|
|
mockCurrentProjectState.currentProject = null;
|
|
|
|
mockRefreshProjects.mockImplementation(async () => {
|
|
mockProjectsState.error = null;
|
|
});
|
|
|
|
const { rerender } = render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: "Retry Connection" })).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Retry Connection" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockRefreshProjects).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
rerender(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Welcome to Fusion")).toBeTruthy();
|
|
}, { timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
describe("didEnterAwaitingApproval", () => {
|
|
it("returns true only when status newly enters awaiting-approval", () => {
|
|
expect(didEnterAwaitingApproval("awaiting-approval", "in-progress")).toBe(true);
|
|
expect(didEnterAwaitingApproval("awaiting-approval", "awaiting-approval")).toBe(false);
|
|
expect(didEnterAwaitingApproval("done", "in-progress")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("App mailbox unread count", () => {
|
|
it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const unreadFetchError = new Error("Mailbox unavailable");
|
|
(fetchUnreadCount as ReturnType<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("shows banner when a task newly enters awaiting-approval", async () => {
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [{ id: "FN-1", title: "Task", description: "x", status: "in-progress", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
pauseTask: vi.fn(),
|
|
resetTask: vi.fn(),
|
|
loadArchivedTasks: vi.fn(),
|
|
ingestCreatedTasks: vi.fn(),
|
|
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-1", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(screen.getByLabelText("Approval requests")).toBeInTheDocument();
|
|
});
|
|
|
|
it("persists dismissals and suppresses repeat alerts for the same approval item", async () => {
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [{ id: "FN-4", title: "Task", description: "x", status: "in-progress", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
pauseTask: vi.fn(),
|
|
resetTask: vi.fn(),
|
|
loadArchivedTasks: vi.fn(),
|
|
ingestCreatedTasks: vi.fn(),
|
|
refreshTasks: vi.fn(),
|
|
lastFetchTimeMs: Date.now(),
|
|
}));
|
|
|
|
const { unmount } = 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-4", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
fireEvent.click(screen.getByLabelText("Dismiss approval notification banner"));
|
|
expect(screen.queryByLabelText("Approval requests")).toBeNull();
|
|
|
|
unmount();
|
|
render(<App />);
|
|
|
|
const latestSubscription = mockSubscribeSse.mock.calls
|
|
.slice()
|
|
.reverse()
|
|
.find(([, sub]) => 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["task:updated"](
|
|
new MessageEvent("task:updated", {
|
|
data: JSON.stringify({ id: "FN-4", status: "awaiting-approval", updatedAt: "2026-05-05T10:00:00.000Z" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(screen.queryByLabelText("Approval requests")).toBeNull();
|
|
});
|
|
|
|
it("does not show banner for already-awaiting tasks", async () => {
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [{ id: "FN-2", title: "Task", description: "x", status: "awaiting-approval", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
pauseTask: vi.fn(),
|
|
resetTask: vi.fn(),
|
|
loadArchivedTasks: vi.fn(),
|
|
ingestCreatedTasks: vi.fn(),
|
|
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;
|
|
};
|
|
|
|
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(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
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(screen.queryByLabelText("Unread chat response")).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(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
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(screen.queryByLabelText("Unread chat response")).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(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("header-chat-view-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
|
|
});
|
|
|
|
await act(async () => {
|
|
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(screen.queryByLabelText("Unread chat response")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("App deep link handling", () => {
|
|
const originalLocation = window.location;
|
|
const originalReplaceState = window.history.replaceState;
|
|
|
|
beforeEach(() => {
|
|
window.history.replaceState = vi.fn();
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/"),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: originalLocation,
|
|
});
|
|
window.history.replaceState = originalReplaceState;
|
|
});
|
|
|
|
it("fetches and opens the task modal when task query param is present", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?task=FN-123"),
|
|
});
|
|
|
|
render(<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 () => {
|
|
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 waitFor(() => {
|
|
expect(fetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
// Should show error toast for project not found
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Project 'nonexistent' not found")).toBeTruthy();
|
|
});
|
|
|
|
// Should NOT fetch the task since project wasn't found
|
|
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not call setCurrentProject when project param matches current project", async () => {
|
|
const project = { id: "proj_123", name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
|
mockProjectsState.projects = [project];
|
|
mockCurrentProjectState.currentProject = project;
|
|
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_123&task=FN-123"),
|
|
});
|
|
|
|
render(<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("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.getByTitle("Missions view")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("App auto-open Settings on unauthenticated", () => {
|
|
it("auto-opens onboarding modal when all providers are unauthenticated and onboarding not complete", async () => {
|
|
// fetchGlobalSettings returns {} by default (modelOnboardingComplete is undefined)
|
|
render(<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.queryByText("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
|
|
// fetchSettings is called twice: once by App useEffect, once by SettingsModal
|
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(2));
|
|
|
|
// Authentication section should be active — auth status is fetched when section is active
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Anthropic")).toBeTruthy();
|
|
});
|
|
expect(screen.getByText("GitHub")).toBeTruthy();
|
|
|
|
// Onboarding modal should NOT be open
|
|
expect(screen.queryByText("Set Up AI")).toBeNull();
|
|
});
|
|
|
|
it("does NOT auto-open anything when at least one provider is authenticated and default model is set", async () => {
|
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
providers: [
|
|
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
|
{ id: "github", name: "GitHub", authenticated: false },
|
|
],
|
|
});
|
|
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
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.queryByText("Settings")).toBeNull();
|
|
|
|
// Onboarding modal should NOT be open
|
|
expect(screen.queryByText("Set Up AI")).toBeNull();
|
|
});
|
|
|
|
it("treats authenticated API-key providers as valid auth for onboarding checks", async () => {
|
|
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
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({
|
|
defaultProvider: "openrouter",
|
|
defaultModelId: "gpt-4o",
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
|
|
|
expect(screen.queryByText("Settings")).toBeNull();
|
|
expect(screen.queryByText("Set Up AI")).toBeNull();
|
|
});
|
|
|
|
it("auto-opens onboarding when providers are authenticated but default model is missing", async () => {
|
|
(fetchAuthStatus as ReturnType<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>).mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
|
|
|
// Settings modal should NOT be open
|
|
expect(screen.queryByText("Settings")).toBeNull();
|
|
// Onboarding modal should NOT be open
|
|
expect(screen.queryByText("Set Up AI")).toBeNull();
|
|
});
|
|
|
|
it("re-opening Settings via gear icon defaults to Authentication tab after closing onboarding", async () => {
|
|
// fetchGlobalSettings returns {} by default → onboarding opens
|
|
render(<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 gear icon button
|
|
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)
|
|
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 () => {
|
|
// 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 global pause (hard stop)", () => {
|
|
it("initializes global pause state from fetchSettings", async () => {
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
globalPause: true,
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
// When globally paused, the stop button should show "Start AI engine"
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Start AI engine")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("shows Stop button when globalPause is false", async () => {
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
globalPause: false,
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("toggles global pause state and calls updateSettings", async () => {
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
globalPause: false,
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
// Wait for initial render
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
|
});
|
|
|
|
// Click the stop button
|
|
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
|
|
|
|
// Should optimistically switch to "Start" state
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Start AI engine")).toBeTruthy();
|
|
});
|
|
|
|
// Should call updateSettings with globalPause and manual reason
|
|
expect(updateSettings).toHaveBeenCalledWith(
|
|
{ globalPause: true, globalPauseReason: "manual" },
|
|
"proj_123",
|
|
);
|
|
});
|
|
|
|
it("reverts global pause state on updateSettings failure", async () => {
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
globalPause: false,
|
|
});
|
|
(updateSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
|
});
|
|
|
|
// Click the stop button — will fail
|
|
fireEvent.click(screen.getByTestId("engine-control-main-btn"));
|
|
|
|
// Should revert back to "Stop" state after failure
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("engine-control-main-btn")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("App engine pause (soft pause)", () => {
|
|
it("initializes engine pause state from fetchSettings", async () => {
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
enginePaused: true,
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy();
|
|
});
|
|
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
|
expect(screen.getByTitle("Resume scheduling")).toBeTruthy();
|
|
});
|
|
|
|
it("shows Pause button when enginePaused is false", async () => {
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
enginePaused: false,
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("toggles engine pause state and calls updateSettings", async () => {
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
enginePaused: false,
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("engine-control-chevron-btn")).toBeTruthy();
|
|
});
|
|
|
|
// Click the pause button
|
|
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
|
fireEvent.click(screen.getByTestId("engine-control-pause-triage-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true }, "proj_123");
|
|
});
|
|
fireEvent.click(screen.getByTestId("engine-control-chevron-btn"));
|
|
expect(screen.getByTitle("Resume scheduling")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("App view switching", () => {
|
|
it("opens research view from overflow and persists view selection", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {
|
|
...defaultSettings.experimentalFeatures,
|
|
researchView: true,
|
|
},
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
fireEvent.click(await screen.findByTestId("view-overflow-research"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("research-view")).toBeInTheDocument();
|
|
expect(localStorage.getItem(taskViewStorageKey())).toBe("research");
|
|
});
|
|
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
|
|
it("opens evals view from overflow and persists view selection", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
fireEvent.click(await screen.findByTestId("view-overflow-evals"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("evals-view")).toBeInTheDocument();
|
|
expect(localStorage.getItem(taskViewStorageKey())).toBe("evals");
|
|
});
|
|
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
|
|
it("does not expose research navigation when research feature is disabled", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: {
|
|
...defaultSettings.experimentalFeatures,
|
|
researchView: false,
|
|
},
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
expect(screen.queryByTestId("view-overflow-research")).not.toBeInTheDocument();
|
|
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
it("initializes research view from persisted task-view when feature-enabled", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
localStorage.setItem(taskViewStorageKey(), "research");
|
|
(fetchSettings as ReturnType<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.getByTitle("List view")).toBeTruthy();
|
|
});
|
|
|
|
// Click to switch to list view
|
|
fireEvent.click(screen.getByTitle("List view"));
|
|
|
|
// List view should be rendered (it has a different structure)
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".list-view")).toBeTruthy();
|
|
});
|
|
|
|
// Cleanup
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
it("switches back to Board view from list view", async () => {
|
|
// Set project mode so board/list view is available
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
|
|
render(<App />);
|
|
|
|
// Wait for the header to render
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("List view")).toBeTruthy();
|
|
});
|
|
|
|
// Switch to list view
|
|
fireEvent.click(screen.getByTitle("List view"));
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".list-view")).toBeTruthy();
|
|
});
|
|
|
|
// Switch back to board view
|
|
fireEvent.click(screen.getByTitle("Board view"));
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".board")).toBeTruthy();
|
|
});
|
|
|
|
// Cleanup
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
it("opens the NewTaskModal from the list view new-task button", async () => {
|
|
// Set project mode so board/list view is available
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("List view")).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTitle("List view"));
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".list-view")).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("+ New Task"));
|
|
|
|
// The NewTaskModal should be visible with its header and description field
|
|
await waitFor(() => {
|
|
expect(screen.getByText("New Task")).toBeTruthy();
|
|
expect(screen.getByPlaceholderText("What needs to be done?")).toBeTruthy();
|
|
});
|
|
|
|
// Cleanup
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
it("persists view preference to localStorage", async () => {
|
|
// Clear any previous value and set project mode
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
|
|
render(<App />);
|
|
|
|
// Wait for the header to render
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("List view")).toBeTruthy();
|
|
});
|
|
|
|
// Switch to list view
|
|
fireEvent.click(screen.getByTitle("List view"));
|
|
|
|
// Should have saved to localStorage
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(taskViewStorageKey())).toBe("list");
|
|
});
|
|
|
|
// Cleanup
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
it("initializes view from localStorage if available", async () => {
|
|
// Set localStorage to list view and project mode
|
|
localStorage.setItem(taskViewStorageKey(), "list");
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
|
|
render(<App />);
|
|
|
|
// Wait for the app to render
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".list-view")).toBeTruthy();
|
|
});
|
|
|
|
// List view should be active
|
|
expect(screen.getByTitle("List view").className).toContain("active");
|
|
|
|
// Cleanup
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
it("renders plugin-hosted dashboard view from persisted task view id", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph");
|
|
(fetchPluginDashboardViews as ReturnType<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("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.getByTitle("Board view").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,
|
|
},
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
fireEvent.click(screen.getByTestId("view-overflow-todos"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("todo-planning-button"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Planning Mode")).toBeInTheDocument();
|
|
});
|
|
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
it("shows view toggle buttons in header including agents", async () => {
|
|
render(<App />);
|
|
|
|
// Wait for the header to render with view toggle
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Board view")).toBeTruthy();
|
|
expect(screen.getByTitle("List view")).toBeTruthy();
|
|
expect(screen.getByTitle("Agents view")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("hides agent view controls when no project is active", async () => {
|
|
mockCurrentProjectState.currentProject = null;
|
|
localStorage.setItem("kb-dashboard-view-mode", "overview");
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByTitle("Agents view")).toBeNull();
|
|
});
|
|
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
it("renders AgentsView when agents view is selected", async () => {
|
|
render(<App />);
|
|
|
|
const agentsViewButton = await screen.findByTitle("Agents view", {}, { timeout: 5000 });
|
|
|
|
// Click to switch to agents view
|
|
fireEvent.click(agentsViewButton);
|
|
|
|
// Agents view should be rendered (it has a agents-view container)
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".agents-view")).toBeTruthy();
|
|
}, { timeout: 5000 });
|
|
|
|
// Should NOT show board or list view
|
|
expect(document.querySelector(".board")).toBeNull();
|
|
expect(document.querySelector(".list-view")).toBeNull();
|
|
});
|
|
|
|
it("persists agents view preference to localStorage", async () => {
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
|
|
render(<App />);
|
|
|
|
const agentsViewButton = await screen.findByTitle("Agents view", {}, { timeout: 5000 });
|
|
|
|
fireEvent.click(agentsViewButton);
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(taskViewStorageKey())).toBe("agents");
|
|
}, { timeout: 5000 });
|
|
});
|
|
|
|
it("initializes agents view from localStorage if saved", async () => {
|
|
localStorage.setItem(taskViewStorageKey(), "agents");
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".agents-view")).toBeTruthy();
|
|
});
|
|
|
|
expect(screen.getByTitle("Agents view").className).toContain("active");
|
|
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
|
|
it("renders agents view button when agentsView experimental feature is disabled", async () => {
|
|
// Override the default mock to exclude agentsView
|
|
vi.mocked(fetchSettings).mockResolvedValue({
|
|
...defaultSettings,
|
|
experimentalFeatures: { insights: true, skillsView: true }, // no agentsView
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Board view")).toBeTruthy();
|
|
});
|
|
|
|
expect(screen.getByTitle("Agents view")).toBeTruthy();
|
|
|
|
// Cleanup: restore default mock
|
|
vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
|
|
});
|
|
|
|
// ── Insights View ──────────────────────────────────────────────────
|
|
|
|
it("renders InsightsView when insights view is selected", async () => {
|
|
render(<App />);
|
|
|
|
// Wait for the header to render
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy();
|
|
});
|
|
|
|
// Open the overflow menu and click Insights
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
fireEvent.click(screen.getByTestId("view-overflow-insights"));
|
|
|
|
// Insights view should be rendered (it has a insights-view container)
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".insights-view")).toBeTruthy();
|
|
});
|
|
|
|
// Should NOT show board, list, or agents view
|
|
expect(document.querySelector(".board")).toBeNull();
|
|
expect(document.querySelector(".list-view")).toBeNull();
|
|
expect(document.querySelector(".agents-view")).toBeNull();
|
|
});
|
|
|
|
it("creates a real triage task from insights using dashboard task creation flow", async () => {
|
|
mockUseInsights.mockImplementation(() => ({
|
|
sections: [
|
|
{
|
|
category: "features",
|
|
label: "Features",
|
|
items: [
|
|
{
|
|
id: "INS-1",
|
|
projectId: DEFAULT_PROJECT_ID,
|
|
title: "Insight title",
|
|
content: "Insight content",
|
|
category: "features",
|
|
status: "generated",
|
|
fingerprint: "fp-ins-1",
|
|
provenance: { trigger: "manual" },
|
|
lastRunId: null,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
],
|
|
isLoading: false,
|
|
error: null,
|
|
},
|
|
],
|
|
loading: false,
|
|
error: null,
|
|
latestRun: null,
|
|
isRunInFlight: false,
|
|
runError: null,
|
|
refresh: vi.fn(),
|
|
runInsights: vi.fn(),
|
|
dismiss: vi.fn(),
|
|
createTask: vi.fn().mockResolvedValue({
|
|
title: "Task from insight",
|
|
description: "Use this insight as a task description",
|
|
}),
|
|
dismissStates: new Map(),
|
|
createTaskStates: new Map(),
|
|
totalCount: 1,
|
|
dismissedCount: 0,
|
|
}));
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
fireEvent.click(screen.getByTestId("view-overflow-insights"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("create-task-INS-1")).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("create-task-INS-1"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockCreateTask).toHaveBeenCalledWith({
|
|
title: "Task from insight",
|
|
description: "Use this insight as a task description",
|
|
column: "triage",
|
|
source: {
|
|
sourceType: "dashboard_ui",
|
|
sourceMetadata: {
|
|
origin: "insights",
|
|
insightId: "INS-1",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it("persists insights view preference to localStorage", async () => {
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
fireEvent.click(screen.getByTestId("view-overflow-insights"));
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem(taskViewStorageKey())).toBe("insights");
|
|
});
|
|
});
|
|
|
|
it("initializes insights view from localStorage if saved", async () => {
|
|
localStorage.setItem(taskViewStorageKey(), "insights");
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".insights-view")).toBeTruthy();
|
|
});
|
|
|
|
// Overflow trigger should be active when view is insights
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger").className).toContain("active");
|
|
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
|
|
it("project switch rehydrates each project's own scoped task-view", async () => {
|
|
const projectA = { id: "proj_a", name: "Project A", path: "/a", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
|
const projectB = { id: "proj_b", name: "Project B", path: "/b", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
|
|
|
// Set different views for each project
|
|
localStorage.setItem("kb:proj_a:kb-dashboard-task-view", "insights");
|
|
localStorage.setItem("kb:proj_b:kb-dashboard-task-view", "agents");
|
|
|
|
mockProjectsState.projects = [projectA, projectB];
|
|
mockCurrentProjectState.currentProject = projectA;
|
|
|
|
render(<App />);
|
|
|
|
// Wait for project A's insights view to load
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".insights-view")).toBeTruthy();
|
|
});
|
|
|
|
// Verify overflow trigger is active
|
|
expect(screen.getByTestId("view-toggle-overflow-trigger").className).toContain("active");
|
|
|
|
// Cleanup
|
|
localStorage.removeItem("kb:proj_a:kb-dashboard-task-view");
|
|
localStorage.removeItem("kb:proj_b:kb-dashboard-task-view");
|
|
});
|
|
|
|
it("does not render insights view button when insights experimental feature is disabled", async () => {
|
|
// Keep at least one overflow item enabled so the overflow trigger still renders.
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { insights: false },
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
// Wait for the header to render
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Board view")).toBeTruthy();
|
|
});
|
|
|
|
// Open the overflow menu - Insights item should not be rendered
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
expect(screen.queryByTestId("view-overflow-insights")).toBeNull();
|
|
});
|
|
|
|
it("keeps experimental views off until settings load and falls back to board when no flag is enabled", async () => {
|
|
localStorage.setItem(taskViewStorageKey(), "insights");
|
|
|
|
let resolveSettings: ((settings: Settings) => void) | undefined;
|
|
vi.mocked(fetchSettings).mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveSettings = resolve as (settings: Settings) => void;
|
|
}),
|
|
);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Board view")).toBeTruthy();
|
|
});
|
|
|
|
expect(document.querySelector(".insights-view")).toBeNull();
|
|
expect(document.querySelector(".board")).toBeNull();
|
|
|
|
resolveSettings?.({
|
|
...defaultSettings,
|
|
experimentalFeatures: {},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".board")).toBeTruthy();
|
|
});
|
|
|
|
expect(document.querySelector(".insights-view")).toBeNull();
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
|
|
it("does not render memory view button when memoryView experimental feature is disabled", async () => {
|
|
// Keep another overflow item enabled so the overflow trigger still renders.
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { memoryView: false, insights: true },
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
// Wait for the header to render
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Board view")).toBeTruthy();
|
|
});
|
|
|
|
// Open the overflow menu - Memory item should not be rendered
|
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
|
expect(screen.queryByTestId("view-toggle-memory")).toBeNull();
|
|
});
|
|
|
|
it("redirects to board when memoryView experimental feature is disabled and taskView is memory", async () => {
|
|
// Set localStorage to memory view but memoryView is disabled
|
|
localStorage.setItem(taskViewStorageKey(), "memory");
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { memoryView: false },
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
// Wait for the app to settle
|
|
await waitFor(() => {
|
|
expect(fetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
// Should redirect to board view since memory is disabled
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".board")).toBeTruthy();
|
|
});
|
|
|
|
// Cleanup
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
|
|
it("renders goals view when goalsView experimental feature is enabled", async () => {
|
|
localStorage.setItem(taskViewStorageKey(), "goalsView");
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { goalsView: true },
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("goals-view")).toBeTruthy();
|
|
});
|
|
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
|
|
it("redirects to board when goalsView experimental feature is disabled and taskView is goalsView", async () => {
|
|
localStorage.setItem(taskViewStorageKey(), "goalsView");
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...defaultSettings,
|
|
experimentalFeatures: { goalsView: false },
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchSettings).toHaveBeenCalled();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector(".board")).toBeTruthy();
|
|
});
|
|
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
});
|
|
|
|
describe("App GitHub import", () => {
|
|
it("opens GitHub import modal when import button is clicked", async () => {
|
|
render(<App />);
|
|
|
|
// Wait for the header to render
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Import from GitHub")).toBeTruthy();
|
|
});
|
|
|
|
// Click the import button
|
|
fireEvent.click(screen.getByTitle("Import from GitHub"));
|
|
|
|
// Modal should be visible
|
|
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
|
});
|
|
|
|
it("closes GitHub import modal on cancel", async () => {
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Import from GitHub")).toBeTruthy();
|
|
});
|
|
|
|
// Open the modal
|
|
fireEvent.click(screen.getByTitle("Import from GitHub"));
|
|
|
|
// Scope interactions to the GitHub import modal to avoid clicking Cancel
|
|
// buttons from other overlays (e.g. onboarding wizard).
|
|
const modalHeading = await screen.findByRole("heading", { name: "Import from GitHub" });
|
|
const modalOverlay = modalHeading.closest(".modal-overlay");
|
|
expect(modalOverlay).toBeTruthy();
|
|
|
|
const cancelButton = within(modalOverlay as HTMLElement).getByRole("button", { name: /^Cancel$/i });
|
|
fireEvent.click(cancelButton);
|
|
|
|
// Modal heading should be gone after cancel closes the overlay.
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("heading", { name: "Import from GitHub" })).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("App Planning Mode", () => {
|
|
it("opens Planning Mode modal when plan button is clicked", async () => {
|
|
render(<App />);
|
|
|
|
// Wait for the header to render
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
|
|
});
|
|
|
|
// Click the plan button
|
|
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
|
|
|
|
// Planning modal should be visible
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Planning Mode")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("closes Planning Mode modal on close button click", async () => {
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
|
|
});
|
|
|
|
// Open the modal
|
|
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Planning Mode")).toBeTruthy();
|
|
});
|
|
|
|
// Close the modal using the close button
|
|
fireEvent.click(screen.getByLabelText("Close"));
|
|
|
|
// Modal should be closed
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("Transform your idea into a detailed task")).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("renders planning modal with correct initial state", async () => {
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
|
|
});
|
|
|
|
// Open the modal
|
|
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
|
|
|
|
// Initial view should show
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Transform your idea into a detailed task")).toBeTruthy();
|
|
expect(screen.getByPlaceholderText(/e.g., Build a user authentication system with login/)).toBeTruthy();
|
|
expect(screen.getByText("Start Planning")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Script run flow", () => {
|
|
it("calls runScript API and returns session info", async () => {
|
|
render(<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", () => {
|
|
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 = screen.getByTestId("scripts-btn");
|
|
await act(async () => {
|
|
fireEvent.click(scriptsBtn);
|
|
});
|
|
|
|
// Wait for the dropdown menu to appear and click "Manage Scripts..."
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy();
|
|
});
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
|
});
|
|
|
|
// Wait for the Scripts modal to open and load scripts
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
|
});
|
|
|
|
// Click the Run button on the "build" script
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByTestId("run-script-build"));
|
|
});
|
|
|
|
// The Scripts modal should now be closed
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("scripts-modal")).toBeNull();
|
|
});
|
|
|
|
// The TerminalModal should be open (not the old ScriptRunDialog)
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
|
});
|
|
|
|
// ScriptRunDialog should NOT be rendered at all
|
|
expect(screen.queryByTestId("script-run-dialog-overlay")).toBeNull();
|
|
});
|
|
|
|
it("allows reopening ScriptsModal after closing TerminalModal", async () => {
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Settings")).toBeTruthy();
|
|
});
|
|
|
|
// Open the Scripts modal and run a script
|
|
const scriptsBtn = screen.getByTestId("scripts-btn");
|
|
await act(async () => {
|
|
fireEvent.click(scriptsBtn);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy();
|
|
});
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
|
});
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByTestId("run-script-build"));
|
|
});
|
|
|
|
// Wait for TerminalModal to open
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
|
});
|
|
expect(screen.queryByTestId("scripts-modal")).toBeNull();
|
|
|
|
// Close the TerminalModal
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByTestId("terminal-close-btn"));
|
|
});
|
|
|
|
// TerminalModal should be closed
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("terminal-modal")).toBeNull();
|
|
});
|
|
|
|
// Reopen the Scripts modal
|
|
await act(async () => {
|
|
fireEvent.click(scriptsBtn);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy();
|
|
});
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
|
});
|
|
|
|
// Scripts modal should open again cleanly
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("does not call runScript API — command is sent directly to terminal", async () => {
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTitle("Settings")).toBeTruthy();
|
|
});
|
|
|
|
// Open the Scripts modal
|
|
const scriptsBtn = screen.getByTestId("scripts-btn");
|
|
await act(async () => {
|
|
fireEvent.click(scriptsBtn);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy();
|
|
});
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("scripts-modal")).toBeTruthy();
|
|
});
|
|
|
|
// Click Run on the "build" script
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByTestId("run-script-build"));
|
|
});
|
|
|
|
// TerminalModal should open
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
|
});
|
|
|
|
// runScript API should NOT have been called — command goes directly to terminal
|
|
expect(runScript).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("App footer-safe project layout", () => {
|
|
afterEach(() => {
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
localStorage.removeItem(taskViewStorageKey());
|
|
});
|
|
|
|
it("wraps project content in project-content div with footer class when project is selected", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
|
|
render(<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
|
|
const settingsBtn = screen.getByRole("button", { name: /settings/i });
|
|
fireEvent.click(settingsBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Settings")).toBeTruthy();
|
|
});
|
|
|
|
// Navigate to Authentication section (it should be default or click to ensure)
|
|
const authSections = await screen.findAllByText("Authentication");
|
|
const authSection = authSections[0];
|
|
fireEvent.click(authSection);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchAuthStatus).toHaveBeenCalled();
|
|
});
|
|
|
|
// Click Reopen onboarding guide button
|
|
const reopenBtn = screen.getByText("Reopen onboarding guide");
|
|
fireEvent.click(reopenBtn);
|
|
|
|
// Onboarding modal should now be open
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("reopened modal shows hydrated model state from global settings", async () => {
|
|
// Mock fetchGlobalSettings to return hydrated settings
|
|
(fetchGlobalSettings as ReturnType<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
|
|
const settingsBtn = screen.getByRole("button", { name: /settings/i });
|
|
fireEvent.click(settingsBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Settings")).toBeTruthy();
|
|
});
|
|
|
|
// Navigate to Authentication section
|
|
const authSections = await screen.findAllByText("Authentication");
|
|
const authSection = authSections[0];
|
|
fireEvent.click(authSection);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchAuthStatus).toHaveBeenCalled();
|
|
});
|
|
|
|
// Click Reopen onboarding guide button from Authentication section
|
|
const reopenBtn = await screen.findByText("Reopen onboarding guide");
|
|
fireEvent.click(reopenBtn);
|
|
|
|
// Wait for onboarding modal to open
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
|
});
|
|
|
|
// The model dropdown should be pre-populated with the saved default
|
|
// Check that the dropdown shows the saved model is selected
|
|
const dropdown = await screen.findByTestId("mock-model-dropdown");
|
|
expect((dropdown as HTMLSelectElement).value).toBe("anthropic/claude-sonnet-4-5");
|
|
});
|
|
});
|
|
|
|
describe("App auth token recovery dialog", () => {
|
|
it("opens as a non-dismissable blocking dialog when daemon auth recovery is required", async () => {
|
|
render(<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();
|
|
|
|
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
|
|
|
|
expect((screen.getByTestId("working-branch-filter") as HTMLSelectElement).value).toBe("");
|
|
expect((screen.getByTestId("target-branch-filter") as HTMLSelectElement).value).toBe("");
|
|
expect(localStorage.getItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectTwoId))).toBeNull();
|
|
expect(localStorage.getItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectTwoId))).toBeNull();
|
|
});
|
|
|
|
it("composes with search and does not affect list view tasks", async () => {
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [
|
|
makeTask("FN-4", "Alpha Search", "feature/a", "main"),
|
|
makeTask("FN-5", "Beta Search", "feature/b", "main"),
|
|
],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
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.getByTitle("List view"));
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Alpha Search")).toBeTruthy();
|
|
expect(screen.getByText("Beta Search")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("App shell connection status plumbing", () => {
|
|
it("loads shell connection status for native shell host", async () => {
|
|
mockShellHostContextValue.host = { kind: "desktop-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" };
|
|
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
|
|
hostKind: "desktop-shell",
|
|
available: true,
|
|
mode: "remote",
|
|
profileLabel: "Prod",
|
|
serverOrigin: "https://fusion.example.com",
|
|
openConnectionManager: async () => ({ ok: true }),
|
|
});
|
|
|
|
render(<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("renders shell connection status for mobile shell host in mobile More sheet only", async () => {
|
|
mockUseViewportMode.mockReturnValue("mobile");
|
|
mockShellHostContextValue.host = { kind: "mobile-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" };
|
|
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
|
|
hostKind: "mobile-shell",
|
|
available: true,
|
|
mode: "remote",
|
|
profileLabel: "Mobile",
|
|
serverOrigin: "https://fusion.example.com",
|
|
openConnectionManager: async () => ({ ok: true }),
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
|
expect(screen.getByTestId("mobile-nav-tab-more")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
|
expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1);
|
|
expect(screen.getByTestId("mobile-more-shell-connection")).toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps desktop shell connection status in header and out of mobile sheet", async () => {
|
|
mockUseViewportMode.mockReturnValue("desktop");
|
|
mockShellHostContextValue.host = { kind: "desktop-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" };
|
|
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
|
|
hostKind: "desktop-shell",
|
|
available: true,
|
|
mode: "remote",
|
|
profileLabel: "Prod",
|
|
serverOrigin: "https://fusion.example.com",
|
|
openConnectionManager: async () => ({ ok: true }),
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1);
|
|
});
|
|
|
|
expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull();
|
|
});
|
|
});
|