feat(FN-3344): add navigation history hook unit and integration tests
Merged test(FN-3344): added comprehensive unit and integration tests for the navigation history hook, covering 470 lines of test coverage across navigation state management, history tracking, and edge cases. Fusion-Task-Id: FN-3344
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Integration tests for mobile back-navigation (Android back button / iOS swipe-back).
|
||||
*
|
||||
* Verifies that the useNavigationHistory hook integration in App.tsx correctly:
|
||||
* - Does NOT push history entries when opening modals on desktop
|
||||
* - Does NOT dismiss modals on popstate on desktop
|
||||
* - Pushes history entries when opening modals on mobile
|
||||
* - Dismisses modals on popstate on mobile
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import type { ProjectInfo } from "../../api";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
// ── API mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
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: true },
|
||||
{ id: "github", name: "GitHub", authenticated: true },
|
||||
],
|
||||
}),
|
||||
),
|
||||
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({})),
|
||||
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-1", command: "echo" })),
|
||||
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
|
||||
});
|
||||
});
|
||||
|
||||
// ── Hook mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: (_options?: any) => mockUseTasks(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useInsights", () => ({
|
||||
useInsights: () => ({
|
||||
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/useRemoteNodeData", () => ({
|
||||
useRemoteNodeData: vi.fn(() => ({
|
||||
projects: [], tasks: [], health: null, loading: false,
|
||||
error: null, refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
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(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockNodeContextValue = {
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/model-onboarding-state", () => ({
|
||||
isOnboardingResumable: () => false,
|
||||
getOnboardingResumeStep: () => null,
|
||||
getOnboardingState: () => null,
|
||||
saveOnboardingState: vi.fn(),
|
||||
clearOnboardingState: vi.fn(),
|
||||
isOnboardingCompleted: () => false,
|
||||
markOnboardingCompleted: vi.fn(),
|
||||
markStepSkipped: vi.fn(),
|
||||
getOnboardingCompletedAt: () => null,
|
||||
getSkippedSteps: () => [],
|
||||
getStepData: () => null,
|
||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
||||
}));
|
||||
|
||||
vi.mock("../../components/TaskDetailModal", () => ({
|
||||
TaskDetailModal: ({ task, onClose }: { task: { id: string; title?: string }; onClose: () => void }) => (
|
||||
<div className="modal-overlay open" data-testid="task-detail-modal">
|
||||
<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/SettingsModal", () => ({
|
||||
SettingsModal: ({ onClose }: { onClose: () => void }) => (
|
||||
<div className="modal-overlay open" data-testid="settings-modal">
|
||||
<h2>Settings</h2>
|
||||
<button type="button" data-testid="settings-close-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/GitHubImportModal", () => ({
|
||||
GitHubImportModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div className="modal-overlay open" data-testid="github-import-modal"><h2>Import from GitHub</h2></div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/PlanningModeModal", () => ({
|
||||
PlanningModeModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div className="modal-overlay open" data-testid="planning-modal"><h2>Planning Mode</h2></div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/AgentsView", () => ({
|
||||
AgentsView: () => <div data-testid="agents-view">Agents view</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ResearchView", () => ({
|
||||
ResearchView: () => <div data-testid="research-view">Research</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/TodoView", () => ({
|
||||
TodoView: () => <div data-testid="todo-view">Todo</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/QuickChatFAB", () => ({
|
||||
QuickChatFAB: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ScriptsModal", () => ({
|
||||
ScriptsModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div className="modal-overlay open" data-testid="scripts-modal"><h2>Scripts</h2></div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/TerminalModal", () => ({
|
||||
TerminalModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div className="modal-overlay open" data-testid="terminal-modal"><h2>Terminal</h2></div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/SetupWizardModal", () => ({
|
||||
SetupWizardModal: () => <div>Welcome to Fusion</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ModelOnboardingModal", () => ({
|
||||
ModelOnboardingModal: ({ onComplete }: { onComplete: () => void }) => (
|
||||
<div className="modal-overlay open">
|
||||
<h2>Set Up AI</h2>
|
||||
<button type="button" onClick={onComplete}>Skip for now</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: () => <select data-testid="mock-model-dropdown"><option>Select…</option></select>,
|
||||
}));
|
||||
|
||||
// ── Project state mocks ────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PROJECT_ID = "proj_123";
|
||||
|
||||
const mockProjectsState = {
|
||||
projects: [] as ProjectInfo[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
};
|
||||
|
||||
const mockCurrentProjectState = {
|
||||
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: vi.fn(async () => {}),
|
||||
register: vi.fn(),
|
||||
update: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useCurrentProject", () => ({
|
||||
useCurrentProject: () => mockCurrentProjectState,
|
||||
}));
|
||||
|
||||
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()),
|
||||
}),
|
||||
}));
|
||||
|
||||
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(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// ── Viewport / keyboard mocks ──────────────────────────────────────────────
|
||||
|
||||
const mockUseViewportMode = vi.fn(() => "desktop");
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: (..._args: unknown[]) => mockUseViewportMode(..._args),
|
||||
getViewportMode: () => "desktop",
|
||||
}));
|
||||
|
||||
const mockUseMobileKeyboard = vi.fn(() => ({
|
||||
keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (..._args: unknown[]) => mockUseMobileKeyboard(..._args),
|
||||
}));
|
||||
|
||||
// ── Import App AFTER all mocks ─────────────────────────────────────────────
|
||||
|
||||
import { App } from "../../App";
|
||||
|
||||
function dispatchPopState(state: Record<string, unknown> | null) {
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Navigation history integration", () => {
|
||||
const originalPushState = window.history.pushState;
|
||||
const originalReplaceState = window.history.replaceState;
|
||||
|
||||
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(),
|
||||
}));
|
||||
mockProjectsState.projects = [];
|
||||
mockProjectsState.loading = false;
|
||||
mockProjectsState.error = null;
|
||||
mockCurrentProjectState.currentProject = {
|
||||
id: DEFAULT_PROJECT_ID,
|
||||
name: "Test Project",
|
||||
path: "/test",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
mockCurrentProjectState.setCurrentProject.mockClear();
|
||||
mockCurrentProjectState.clearCurrentProject.mockClear();
|
||||
mockNodeContextValue.currentNode = null;
|
||||
mockNodeContextValue.currentNodeId = null;
|
||||
mockNodeContextValue.isRemote = false;
|
||||
mockNodeContextValue.setCurrentNode.mockClear();
|
||||
mockNodeContextValue.clearCurrentNode.mockClear();
|
||||
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false,
|
||||
});
|
||||
|
||||
localStorage.removeItem("fusion-dashboard-current-node");
|
||||
localStorage.removeItem("kb-onboarding-state");
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
|
||||
window.history.pushState = vi.fn();
|
||||
window.history.replaceState = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState = originalPushState;
|
||||
window.history.replaceState = originalReplaceState;
|
||||
});
|
||||
|
||||
async function renderAppAndWait() {
|
||||
const result = render(<App />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Settings")).toBeTruthy();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 1. Desktop: opening Settings does NOT push history entry
|
||||
it("does not push history entry when opening Settings modal on desktop", async () => {
|
||||
await renderAppAndWait();
|
||||
|
||||
const settingsBtn = screen.getByTitle("Settings");
|
||||
fireEvent.click(settingsBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
// On desktop, pushState should NOT be called for modal opens
|
||||
expect(window.history.pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 2. Desktop: popstate does NOT dismiss modals
|
||||
it("does not dismiss Settings modal on popstate in desktop mode", async () => {
|
||||
await renderAppAndWait();
|
||||
|
||||
const settingsBtn = screen.getByTitle("Settings");
|
||||
fireEvent.click(settingsBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("settings-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Simulate back button
|
||||
dispatchPopState({ navIndex: 0 });
|
||||
|
||||
// Settings modal should still be open
|
||||
expect(screen.getByTestId("settings-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
// 3. Desktop: view changes do NOT push history entries
|
||||
it("does not push history entry for view changes on desktop", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
const taskViewStorageKey = scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID);
|
||||
localStorage.setItem(taskViewStorageKey, "board");
|
||||
|
||||
await renderAppAndWait();
|
||||
|
||||
// Switch to agents view
|
||||
const agentsTab = screen.queryByTitle("Agents");
|
||||
if (!agentsTab) return;
|
||||
fireEvent.click(agentsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agents-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// pushState should NOT have been called for view changes on desktop
|
||||
expect(window.history.pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 4. Desktop: popstate does NOT revert view changes
|
||||
it("does not revert view change on popstate in desktop mode", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
const taskViewStorageKey = scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID);
|
||||
localStorage.setItem(taskViewStorageKey, "board");
|
||||
|
||||
await renderAppAndWait();
|
||||
|
||||
const agentsTab = screen.queryByTitle("Agents");
|
||||
if (!agentsTab) return;
|
||||
fireEvent.click(agentsTab);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agents-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Simulate back button
|
||||
dispatchPopState({ navIndex: 0 });
|
||||
|
||||
// Agents view should still be showing (desktop ignores popstate for navigation)
|
||||
expect(screen.getByTestId("agents-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// 5. Verify useNavigationHistory is called with enabled=true on mobile
|
||||
it("calls useNavigationHistory with enabled=true on mobile", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
// On mobile, the Settings button is not in the header — it's in the
|
||||
// MobileNavBar "More" menu. We just verify the mock was called.
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Wait for the app to render something
|
||||
expect(document.querySelector('.project-content')).toBeTruthy();
|
||||
});
|
||||
|
||||
// The useViewportMode hook should have been called and returned "mobile"
|
||||
expect(mockUseViewportMode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user