import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ProjectOverview } from "../ProjectOverview"; import type { ProjectInfo, ProjectHealth } from "@fusion/core"; import { useProjectHealth } from "../../hooks/useProjectHealth"; // Extended type with source node info for cross-node tests interface ProjectInfoWithSource extends ProjectInfo { _sourceNodeName?: string; nodeMappings?: Array<{ nodeId: string; path: string; available: boolean; nodeName?: string }>; } // Default mock implementation function createDefaultHealthMap(projectIds: string[]): Record { return projectIds.reduce((acc, id) => { acc[id] = { projectId: id, status: "active" as const, activeTaskCount: 5, inFlightAgentCount: 2, totalTasksCompleted: 100, totalTasksFailed: 3, updatedAt: new Date().toISOString(), }; return acc; }, {} as Record); } // Mock the hooks vi.mock("../../hooks/useProjectHealth", () => ({ useProjectHealth: vi.fn((projectIds: string[]) => ({ healthMap: createDefaultHealthMap(projectIds), loading: false, error: null, refresh: vi.fn(), refreshProject: vi.fn(), })), })); // Mock lucide-react vi.mock("lucide-react", async () => { const actual = await vi.importActual("lucide-react"); return { ...actual, Plus: () => +, LayoutGrid: () => , Filter: () => , ArrowUpDown: () => , Activity: () => , CheckCircle: () => , AlertCircle: () => , Folder: () => 📁, Inbox: () => 📥, Server: () => 🖥, }; }); // Mock ProjectCard vi.mock("../ProjectCard", () => ({ ProjectCard: ({ project, onSelect, availabilityMappings, }: { project: ProjectInfo; onSelect: (p: ProjectInfo) => void; availabilityMappings?: Array<{ displayName: string; path: string }>; }) => (
mapping.displayName).join(",") || "none"} onClick={() => onSelect(project)} > {project.name} {(availabilityMappings ?? []).map((mapping) => ( {mapping.displayName} ))}
), })); // Mock ProjectGridSkeleton vi.mock("../ProjectGridSkeleton", () => ({ ProjectGridSkeleton: () =>
Loading...
, })); function makeProject(overrides: Partial = {}): ProjectInfoWithSource { const project: ProjectInfoWithSource = { id: "proj_abc123", name: "Test Project", path: "/home/user/projects/test", status: "active", isolationMode: "in-process", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", lastActivityAt: new Date().toISOString(), ...overrides, }; if (!project.nodeMappings && project.nodeId) { project.nodeMappings = [{ nodeId: project.nodeId, path: project.path, available: true }]; } return project; } const noop = () => {}; describe("ProjectOverview", () => { beforeEach(() => { vi.clearAllMocks(); // Reset mock to default state vi.mocked(useProjectHealth).mockImplementation((projectIds: string[]) => ({ healthMap: createDefaultHealthMap(projectIds), loading: false, error: null, refresh: vi.fn(), refreshProject: vi.fn(), })); }); it("renders without crashing with projects", () => { render( ); expect(screen.getByText("Projects")).toBeDefined(); }); it("displays project cards when projects provided", () => { render( ); expect(screen.getByTestId("project-card-proj_1")).toBeDefined(); expect(screen.getByTestId("project-card-proj_2")).toBeDefined(); }); it("shows empty state when no projects", () => { render( ); expect(screen.getByText("No Projects Found")).toBeDefined(); expect(screen.getByText("Add Your First Project")).toBeDefined(); }); it("triggers onAddProject when empty state CTA clicked", () => { const onAddProject = vi.fn(); render( ); fireEvent.click(screen.getByText("Add Your First Project")); expect(onAddProject).toHaveBeenCalled(); }); it("triggers onAddProject when header button clicked", () => { const onAddProject = vi.fn(); render( ); fireEvent.click(screen.getByText("Add Project")); expect(onAddProject).toHaveBeenCalled(); }); it("displays correct stats in header", () => { render( ); // Total projects = 3 - look specifically in stats section const statsSection = screen.getByText("Total").closest(".project-stat__content"); expect(statsSection?.querySelector(".project-stat__value")?.textContent).toBe("3"); }); it("filters projects when clicking filter tabs", async () => { render( ); // Initially shows all projects expect(screen.getByTestId("project-card-proj_1")).toBeDefined(); expect(screen.getByTestId("project-card-proj_2")).toBeDefined(); // Click on "Active" filter fireEvent.click(screen.getByText("Active")); // Should only show active project await waitFor(() => { expect(screen.queryByTestId("project-card-proj_1")).toBeDefined(); }); }); it("shows filter counts on tabs", () => { render( ); // Find the "All" tab and check its count const allTab = screen.getByText("All").closest("button"); expect(allTab?.textContent).toContain("4"); // Active tab should show 2 const activeTab = screen.getByText("Active").closest("button"); expect(activeTab?.textContent).toContain("2"); // Paused tab should show 1 const pausedTab = screen.getByText("Paused").closest("button"); expect(pausedTab?.textContent).toContain("1"); }); it("shows no results message when filter returns empty", () => { render( ); // Click on "Errored" filter - no projects match fireEvent.click(screen.getByText("Errored")); expect(screen.getByText("No projects match the current filter")).toBeDefined(); expect(screen.getByText("Show All Projects")).toBeDefined(); }); it("clears filter when clicking Show All Projects button", () => { render( ); // First filter to errored (empty results) fireEvent.click(screen.getByText("Errored")); expect(screen.getByText("No projects match the current filter")).toBeDefined(); // Click Show All Projects fireEvent.click(screen.getByText("Show All Projects")); // Should be back to showing all expect(screen.getByTestId("project-card-proj_1")).toBeDefined(); }); it("shows loading skeleton when loading prop is true", () => { render( ); expect(screen.getByTestId("project-grid-skeleton")).toBeDefined(); }); it("calls onSelectProject when a project card is clicked", () => { const onSelectProject = vi.fn(); const project = makeProject({ id: "proj_1", name: "Test Project" }); render( ); fireEvent.click(screen.getByTestId("project-card-proj_1")); expect(onSelectProject).toHaveBeenCalledWith(project); }); it("errored tab has special styling when errored projects exist", () => { render( ); // Find the errored filter tab specifically (not the stat label) const erroredTab = screen.getAllByText("Errored").find(el => el.tagName === "BUTTON"); expect(erroredTab?.className).toContain("has-errors"); }); describe("FN-1850: Node filter and badges", () => { it("shows node badge for project with node association", () => { const localNode = { id: "node_local", name: "Local", type: "local" as const, status: "online" as const, maxConcurrent: 2, createdAt: "", updatedAt: "" }; render( ); const card = screen.getByTestId("project-card-proj_1"); expect(card.getAttribute("data-node")).toBe("Local"); }); it("renders node name fallback for remote projects without local node object", () => { // Remote project with _sourceNodeName but no matching local node render( ); const card = screen.getByTestId("project-card-proj_remote"); expect(card.getAttribute("data-node")).toBe("Remote Alpha"); }); it("shows node filter dropdown when projects have multiple nodes", () => { render( ); // Node filter dropdown should be present expect(screen.getByLabelText("Filter by node")).toBeDefined(); }); it("node filter dropdown filters projects by node", async () => { render( ); // Initially shows both projects expect(screen.getByTestId("project-card-proj_1")).toBeDefined(); expect(screen.getByTestId("project-card-proj_2")).toBeDefined(); // Select "Alpha" from the node filter const nodeFilter = screen.getByLabelText("Filter by node"); fireEvent.change(nodeFilter, { target: { value: "node_alpha" } }); // Should only show Alpha project await waitFor(() => { expect(screen.getByTestId("project-card-proj_1")).toBeDefined(); }); expect(screen.queryByTestId("project-card-proj_2")).toBeNull(); }); it("shows nodes stat in header when projects span multiple nodes", () => { render( ); // Nodes stat should be visible const nodesStats = screen.getByText("Nodes").closest(".project-stat__content"); expect(nodesStats?.querySelector(".project-stat__value")?.textContent).toBe("2"); }); it("does not show node filter when all projects are local", () => { render( ); // Node filter should not be present expect(screen.queryByLabelText("Filter by node")).toBeNull(); }); it("does not show nodes stat when only one node (or no node ID)", () => { render( ); // Nodes stat should not be present expect(screen.queryByText("Nodes")).toBeNull(); }); }); describe("mobile responsive structure", () => { it("renders overview with correct class structure for mobile CSS targets", () => { const { container } = render( ); // Verify container class exists for mobile override targeting expect(container.querySelector(".project-overview")).not.toBeNull(); // Verify header section expect(container.querySelector(".project-overview__header")).not.toBeNull(); // Verify stats section expect(container.querySelector(".project-overview__stats")).not.toBeNull(); // Verify filters section expect(container.querySelector(".project-overview__filters")).not.toBeNull(); // Verify grid container expect(container.querySelector(".project-grid")).not.toBeNull(); }); it("renders stat elements with value/label structure for mobile sizing", () => { const { container } = render( ); // Stats should have the nested structure mobile CSS targets const stats = container.querySelectorAll(".project-stat"); expect(stats.length).toBeGreaterThanOrEqual(3); // Total, Active Tasks, Completed stats.forEach((stat) => { expect(stat.querySelector(".project-stat__value")).not.toBeNull(); expect(stat.querySelector(".project-stat__label")).not.toBeNull(); }); }); it("renders filter tabs with correct classes for mobile stacking", () => { const { container } = render( ); expect(container.querySelector(".project-filter-tabs")).not.toBeNull(); const tabs = container.querySelectorAll(".project-filter-tab"); expect(tabs.length).toBe(4); // All, Active, Paused, Errored tabs.forEach((tab) => { expect(tab.querySelector(".project-filter-count")).not.toBeNull(); }); }); it("renders sort select with aria-label for mobile full-width targeting", () => { const { container } = render( ); expect(container.querySelector(".project-sort")).not.toBeNull(); expect(container.querySelector(".project-sort-select")).not.toBeNull(); expect(screen.getByLabelText("Sort projects")).toBeDefined(); }); it("renders node filter select with correct classes for mobile", () => { const { container } = render( ); expect(container.querySelector(".project-node-filter")).not.toBeNull(); expect(container.querySelector(".project-node-filter-select")).not.toBeNull(); expect(screen.getByLabelText("Filter by node")).toBeDefined(); }); }); describe("FN-1734: Health polling scroll position regression", () => { it("shows project cards when health hook is in loading state but healthMap has existing data", () => { // This is the key regression test for FN-1734: // When background polling refreshes health, loading becomes true but we // should NOT show skeleton if we already have health data vi.mocked(useProjectHealth).mockReturnValue({ healthMap: { proj_1: { projectId: "proj_1", status: "active", activeTaskCount: 5, inFlightAgentCount: 2, totalTasksCompleted: 100, totalTasksFailed: 3, updatedAt: new Date().toISOString(), }, }, loading: false, error: null, refresh: vi.fn(), refreshProject: vi.fn(), }); const { container } = render( ); // Project card should be visible expect(screen.getByTestId("project-card-proj_1")).toBeDefined(); // Skeleton should NOT be shown (we have existing health data) expect(screen.queryByTestId("project-grid-skeleton")).toBeNull(); // Grid should be rendered expect(container.querySelector(".project-grid")).not.toBeNull(); }); it("shows skeleton only when projects exist but no health data has been fetched", () => { // When loading prop is true AND we have no health data yet, // skeleton should be shown render( ); // Skeleton should be shown (projects loading) expect(screen.getByTestId("project-grid-skeleton")).toBeDefined(); }); it("shows skeleton when health hook is loading with no existing health data", () => { // When health hook returns loading=true AND we have no health data yet, // skeleton should be shown even if loading prop is false vi.mocked(useProjectHealth).mockReturnValue({ healthMap: {}, // Empty - no health data yet loading: true, // Health is still loading error: null, refresh: vi.fn(), refreshProject: vi.fn(), }); render( ); // Skeleton should be shown (health loading with no data) expect(screen.getByTestId("project-grid-skeleton")).toBeDefined(); }); }); });