Adds unassigned branch filter options to the dashboard header, allowing users to filter tasks by unassigned status. Updates the Header component with new filter controls and includes corresponding tests for the App and Header components. Fusion-Task-Id: FN-3424
3400 lines
109 KiB
TypeScript
3400 lines
109 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";
|
|
|
|
// No mock needed - tests use localStorage directly
|
|
|
|
const defaultSettings: Settings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 15000,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
recycleWorktrees: false,
|
|
worktreeInitCommand: "",
|
|
testCommand: "",
|
|
buildCommand: "",
|
|
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true },
|
|
};
|
|
|
|
|
|
const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn());
|
|
|
|
vi.mock("../../sse-bus", () => ({
|
|
|
|
subscribeSse: (...args: any[]) => mockSubscribeSse(...args),
|
|
}));
|
|
|
|
vi.mock("../../api", async (importOriginal) => {
|
|
const { createDashboardApiMock } = await import("../../test/mockApi");
|
|
return createDashboardApiMock(() => importOriginal<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([])),
|
|
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
|
|
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
|
|
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
|
|
fetchExecutorStats: vi.fn(() => Promise.resolve({
|
|
globalPause: false,
|
|
enginePaused: false,
|
|
maxConcurrent: 2,
|
|
lastActivityAt: new Date().toISOString(),
|
|
})),
|
|
fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })),
|
|
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-script-1", command: "echo hello" })),
|
|
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
|
|
});
|
|
});
|
|
|
|
const mockCreateTask = vi.fn();
|
|
|
|
const mockUseTasks = vi.fn(() => ({
|
|
tasks: [],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
}));
|
|
|
|
// Accept both old and new hook signatures
|
|
vi.mock("../../hooks/useTasks", () => ({
|
|
useTasks: (_options?: { projectId?: string; searchQuery?: string; sseEnabled?: boolean }) => mockUseTasks(),
|
|
}));
|
|
|
|
// Mock useRemoteNodeData
|
|
const mockUseInsights = vi.fn(() => ({
|
|
sections: [],
|
|
loading: false,
|
|
error: null,
|
|
latestRun: null,
|
|
isRunInFlight: false,
|
|
runError: null,
|
|
refresh: vi.fn(),
|
|
runInsights: vi.fn(),
|
|
dismiss: vi.fn(),
|
|
createTask: vi.fn(),
|
|
dismissStates: new Map(),
|
|
createTaskStates: new Map(),
|
|
totalCount: 0,
|
|
dismissedCount: 0,
|
|
}));
|
|
|
|
vi.mock("../../hooks/useInsights", () => ({
|
|
useInsights: (..._args: unknown[]) => mockUseInsights(),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useRemoteNodeData", () => ({
|
|
useRemoteNodeData: vi.fn(() => ({
|
|
projects: [],
|
|
tasks: [],
|
|
health: null,
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
// Mock useRemoteNodeEvents
|
|
vi.mock("../../hooks/useRemoteNodeEvents", () => ({
|
|
useRemoteNodeEvents: vi.fn(() => ({
|
|
isConnected: false,
|
|
lastEvent: null,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useBackgroundSessions", () => ({
|
|
useBackgroundSessions: vi.fn(() => ({
|
|
sessions: [],
|
|
generating: false,
|
|
needsInput: false,
|
|
planningSessions: [],
|
|
dismissSession: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
// Mock NodeContext - default to local mode
|
|
const mockNodeContextValue: {
|
|
currentNode: NodeConfig | null;
|
|
currentNodeId: string | null;
|
|
isRemote: boolean;
|
|
setCurrentNode: ReturnType<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),
|
|
}));
|
|
|
|
// 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("../../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/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/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 } from "../../App";
|
|
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
|
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
|
|
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
|
|
|
|
async function waitForAppShell(): Promise<void> {
|
|
await waitFor(() => {
|
|
expect(fetchSettings).toHaveBeenCalled();
|
|
expect(screen.getByTitle("Settings")).toBeTruthy();
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockSubscribeSse.mockReset();
|
|
mockSubscribeSse.mockReturnValue(vi.fn());
|
|
mockCreateTask.mockReset();
|
|
mockUseTasks.mockReset();
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
}));
|
|
// Reset mock states
|
|
mockProjectsState.projects = [];
|
|
mockProjectsState.loading = false;
|
|
mockProjectsState.error = null;
|
|
mockRefreshProjects.mockReset();
|
|
mockRefreshProjects.mockImplementation(async () => {});
|
|
mockCurrentProjectState.currentProject = { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" };
|
|
mockCurrentProjectState.setCurrentProject.mockClear();
|
|
mockCurrentProjectState.clearCurrentProject.mockClear();
|
|
// Reset node context mocks
|
|
mockNodeContextValue.currentNode = null;
|
|
mockNodeContextValue.currentNodeId = null;
|
|
mockNodeContextValue.isRemote = false;
|
|
mockNodeContextValue.setCurrentNode.mockClear();
|
|
mockNodeContextValue.clearCurrentNode.mockClear();
|
|
// Clear node selection from localStorage to avoid cross-test leakage
|
|
localStorage.removeItem("fusion-dashboard-current-node");
|
|
// Clear onboarding/chat state from localStorage
|
|
localStorage.removeItem("kb-onboarding-state");
|
|
localStorage.removeItem(scopedKey("kb-chat-active-session", "proj_123"));
|
|
// Reset onboarding state mocks
|
|
mockIsOnboardingResumable.mockReset();
|
|
mockIsOnboardingResumable.mockReturnValue(false);
|
|
mockGetOnboardingResumeStep.mockReset();
|
|
mockGetOnboardingResumeStep.mockReturnValue(null);
|
|
mockGetOnboardingState.mockReset();
|
|
mockGetOnboardingState.mockReturnValue(null);
|
|
mockSaveOnboardingState.mockReset();
|
|
mockClearOnboardingState.mockReset();
|
|
mockIsOnboardingCompleted.mockReset();
|
|
mockIsOnboardingCompleted.mockReturnValue(false);
|
|
mockMarkOnboardingCompleted.mockReset();
|
|
mockMarkStepSkipped.mockReset();
|
|
mockGetOnboardingCompletedAt.mockReset();
|
|
mockGetOnboardingCompletedAt.mockReturnValue(null);
|
|
mockGetSkippedSteps.mockReset();
|
|
mockGetSkippedSteps.mockReturnValue([]);
|
|
mockGetStepData.mockReset();
|
|
mockGetStepData.mockReturnValue(null);
|
|
mockUseInsights.mockReset();
|
|
mockUseInsights.mockImplementation(() => ({
|
|
sections: [],
|
|
loading: false,
|
|
error: null,
|
|
latestRun: null,
|
|
isRunInFlight: false,
|
|
runError: null,
|
|
refresh: vi.fn(),
|
|
runInsights: vi.fn(),
|
|
dismiss: vi.fn(),
|
|
createTask: vi.fn(),
|
|
dismissStates: new Map(),
|
|
createTaskStates: new Map(),
|
|
totalCount: 0,
|
|
dismissedCount: 0,
|
|
}));
|
|
// Reset mobile keyboard and viewport mocks to defaults (desktop, no keyboard)
|
|
mockUseMobileKeyboard.mockReset();
|
|
mockUseMobileKeyboard.mockReturnValue({
|
|
keyboardOverlap: 0,
|
|
viewportHeight: null,
|
|
viewportOffsetTop: 0,
|
|
keyboardOpen: false,
|
|
});
|
|
mockUseViewportMode.mockReset();
|
|
mockUseViewportMode.mockReturnValue("desktop");
|
|
});
|
|
|
|
describe("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("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 chat unread response indicator", () => {
|
|
it("shows unread indicator when assistant message arrives for active session after leaving chat", async () => {
|
|
localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active");
|
|
|
|
render(<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",
|
|
);
|
|
const subscriptionConfig = chatSubscriptionCall?.[1] as {
|
|
events: Record<string, (event: MessageEvent) => void>;
|
|
};
|
|
|
|
await act(async () => {
|
|
subscriptionConfig.events["chat:message:added"](
|
|
new MessageEvent("chat:message:added", {
|
|
data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("does not show unread indicator for non-qualifying chat events", async () => {
|
|
localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active");
|
|
|
|
render(<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",
|
|
);
|
|
const subscriptionConfig = chatSubscriptionCall?.[1] as {
|
|
events: Record<string, (event: MessageEvent) => void>;
|
|
};
|
|
|
|
await act(async () => {
|
|
subscriptionConfig.events["chat:message:added"](
|
|
new MessageEvent("chat:message:added", {
|
|
data: JSON.stringify({ role: "user", sessionId: "sess-active" }),
|
|
}),
|
|
);
|
|
subscriptionConfig.events["chat:message:added"](
|
|
new MessageEvent("chat:message:added", {
|
|
data: JSON.stringify({ role: "assistant", sessionId: "sess-other" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
|
|
});
|
|
|
|
it("clears unread indicator when returning to chat and does not mark while in chat", async () => {
|
|
localStorage.setItem(scopedKey("kb-chat-active-session", "proj_123"), "sess-active");
|
|
|
|
render(<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",
|
|
);
|
|
const subscriptionConfig = chatSubscriptionCall?.[1] as {
|
|
events: Record<string, (event: MessageEvent) => void>;
|
|
};
|
|
|
|
await act(async () => {
|
|
subscriptionConfig.events["chat:message:added"](
|
|
new MessageEvent("chat:message:added", {
|
|
data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId("header-chat-view-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
|
|
});
|
|
|
|
await act(async () => {
|
|
subscriptionConfig.events["chat:message:added"](
|
|
new MessageEvent("chat:message:added", {
|
|
data: JSON.stringify({ role: "assistant", sessionId: "sess-active" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("App deep link handling", () => {
|
|
const originalLocation = window.location;
|
|
const originalReplaceState = window.history.replaceState;
|
|
|
|
beforeEach(() => {
|
|
window.history.replaceState = vi.fn();
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/"),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: originalLocation,
|
|
});
|
|
window.history.replaceState = originalReplaceState;
|
|
});
|
|
|
|
it("fetches and opens the task modal when task query param is present", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?task=FN-123"),
|
|
});
|
|
|
|
render(<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).toHaveBeenCalledTimes(1));
|
|
expect(screen.queryByText("Settings")).toBeNull();
|
|
|
|
// Onboarding modal should NOT be open
|
|
expect(screen.queryByText("Set Up AI")).toBeNull();
|
|
});
|
|
|
|
it("treats authenticated API-key providers as valid auth for onboarding checks", async () => {
|
|
(fetchAuthStatus as ReturnType<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).toHaveBeenCalledTimes(1));
|
|
|
|
expect(screen.queryByText("Settings")).toBeNull();
|
|
expect(screen.queryByText("Set Up AI")).toBeNull();
|
|
});
|
|
|
|
it("auto-opens onboarding when providers are authenticated but default model is missing", async () => {
|
|
(fetchAuthStatus as ReturnType<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).toHaveBeenCalledTimes(1));
|
|
|
|
// Settings modal should NOT be open
|
|
expect(screen.queryByText("Settings")).toBeNull();
|
|
// Onboarding modal should NOT be open
|
|
expect(screen.queryByText("Set Up AI")).toBeNull();
|
|
});
|
|
|
|
it("re-opening Settings via gear icon defaults to Authentication tab after closing onboarding", async () => {
|
|
// fetchGlobalSettings returns {} by default → onboarding opens
|
|
render(<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).toHaveBeenCalledTimes(2));
|
|
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
|
|
|
// Authentication section content should be visible (providers listed)
|
|
expect(screen.getByText("Anthropic")).toBeTruthy();
|
|
|
|
// Click on General to verify General section has Task Prefix
|
|
fireEvent.click(screen.getAllByText("General")[0]);
|
|
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("OnboardingResumeCard", () => {
|
|
const STORAGE_KEY = "fusion_model_onboarding_state";
|
|
|
|
beforeEach(() => {
|
|
// Clear localStorage before each test
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
afterEach(() => {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
localStorage.removeItem("kb-dashboard-view-mode");
|
|
});
|
|
|
|
// Note: These tests verify the integration with localStorage state.
|
|
// The resume card only appears when viewMode === "project" AND currentProject is set.
|
|
// The full integration tests are complex due to the App's initialization flow.
|
|
|
|
it("renders with no localStorage data (no resume card)", async () => {
|
|
// No localStorage data set - resume card should not appear
|
|
render(<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>).mockResolvedValueOnce({
|
|
...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("does not expose research navigation when research feature is disabled", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
...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>).mockResolvedValueOnce({
|
|
...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>).mockResolvedValueOnce({
|
|
...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("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.getByText("Plugin view unavailable")).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>).mockResolvedValueOnce({
|
|
...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, roadmap: 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, roadmap: true },
|
|
});
|
|
|
|
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());
|
|
});
|
|
});
|
|
|
|
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", () => {
|
|
function makeTask(id: string, title: string, branch?: string, baseBranch?: string) {
|
|
return {
|
|
id,
|
|
title,
|
|
description: title,
|
|
column: "todo",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
...(branch ? { branch } : {}),
|
|
...(baseBranch ? { baseBranch } : {}),
|
|
};
|
|
}
|
|
|
|
it("filters board tasks by working and target branch in local mode", async () => {
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [
|
|
makeTask("FN-1", "Task Alpha", "feature/a", "main"),
|
|
makeTask("FN-2", "Task Beta", "feature/b", "release"),
|
|
],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
}));
|
|
|
|
render(<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 branch values", async () => {
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [
|
|
makeTask("FN-1", "Unassigned Task"),
|
|
makeTask("FN-2", "Assigned Task", "feature/a", "main"),
|
|
],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
}));
|
|
|
|
render(<App />);
|
|
await waitForAppShell();
|
|
|
|
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
|
|
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "__none__" } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Unassigned Task")).toBeTruthy();
|
|
expect(screen.queryByText("Assigned Task")).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("composes with search and does not affect list view tasks", async () => {
|
|
mockUseTasks.mockImplementation(() => ({
|
|
tasks: [
|
|
makeTask("FN-4", "Alpha Search", "feature/a", "main"),
|
|
makeTask("FN-5", "Beta Search", "feature/b", "main"),
|
|
],
|
|
createTask: mockCreateTask,
|
|
moveTask: vi.fn(),
|
|
deleteTask: vi.fn(),
|
|
mergeTask: vi.fn(),
|
|
retryTask: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
duplicateTask: vi.fn(),
|
|
archiveTask: vi.fn(),
|
|
unarchiveTask: vi.fn(),
|
|
archiveAllDone: vi.fn(),
|
|
}));
|
|
|
|
render(<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();
|
|
});
|
|
});
|
|
});
|