- Add Project Overview page with responsive grid and health status cards - Add Project Selector dropdown in header for quick project switching - Add Setup Wizard for registering new projects with auto-detection - Implement project drill-down: click project to view its tasks - Add global activity feed with cross-project activity log - Add project health monitoring with real-time status polling - Add deep linking support for tasks via ?task= URL parameter - Add useTasks hook with project context filtering - Add ntfy notification deep link to open tasks in dashboard - Fix terminal error message formatting for agent failures - Unify comment style across codebase (// instead of /** */) - Add comprehensive tests for multi-project components
343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
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";
|
|
|
|
// Mock the hooks
|
|
vi.mock("../../hooks/useProjectHealth", () => ({
|
|
useProjectHealth: vi.fn((projectIds: string[]) => ({
|
|
healthMap: projectIds.reduce((acc, id) => {
|
|
acc[id] = {
|
|
projectId: id,
|
|
status: "active",
|
|
activeTaskCount: 5,
|
|
inFlightAgentCount: 2,
|
|
totalTasksCompleted: 100,
|
|
totalTasksFailed: 3,
|
|
updatedAt: new Date().toISOString(),
|
|
} as ProjectHealth;
|
|
return acc;
|
|
}, {} as Record<string, ProjectHealth>),
|
|
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: () => <span data-testid="plus-icon">+</span>,
|
|
LayoutGrid: () => <span data-testid="grid-icon">⊞</span>,
|
|
Filter: () => <span data-testid="filter-icon">⚙</span>,
|
|
ArrowUpDown: () => <span data-testid="sort-icon">⇅</span>,
|
|
Activity: () => <span data-testid="activity-icon">⚡</span>,
|
|
CheckCircle: () => <span data-testid="check-icon">✓</span>,
|
|
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
|
Folder: () => <span data-testid="folder-icon">📁</span>,
|
|
Inbox: () => <span data-testid="inbox-icon">📥</span>,
|
|
};
|
|
});
|
|
|
|
// Mock ProjectCard
|
|
vi.mock("../ProjectCard", () => ({
|
|
ProjectCard: ({ project, onSelect }: { project: ProjectInfo; onSelect: (p: ProjectInfo) => void }) => (
|
|
<div data-testid={`project-card-${project.id}`} onClick={() => onSelect(project)}>
|
|
{project.name}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
// Mock ProjectGridSkeleton
|
|
vi.mock("../ProjectGridSkeleton", () => ({
|
|
ProjectGridSkeleton: () => <div data-testid="project-grid-skeleton">Loading...</div>,
|
|
}));
|
|
|
|
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
|
return {
|
|
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,
|
|
};
|
|
}
|
|
|
|
const noop = () => {};
|
|
|
|
describe("ProjectOverview", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders without crashing with projects", () => {
|
|
render(
|
|
<ProjectOverview
|
|
projects={[makeProject()]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText("Projects")).toBeDefined();
|
|
});
|
|
|
|
it("displays project cards when projects provided", () => {
|
|
render(
|
|
<ProjectOverview
|
|
projects={[
|
|
makeProject({ id: "proj_1", name: "Project One" }),
|
|
makeProject({ id: "proj_2", name: "Project Two" }),
|
|
]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
|
|
expect(screen.getByTestId("project-card-proj_2")).toBeDefined();
|
|
});
|
|
|
|
it("shows empty state when no projects", () => {
|
|
render(
|
|
<ProjectOverview
|
|
projects={[]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<ProjectOverview
|
|
projects={[]}
|
|
onSelectProject={noop}
|
|
onAddProject={onAddProject}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Add Your First Project"));
|
|
expect(onAddProject).toHaveBeenCalled();
|
|
});
|
|
|
|
it("triggers onAddProject when header button clicked", () => {
|
|
const onAddProject = vi.fn();
|
|
render(
|
|
<ProjectOverview
|
|
projects={[makeProject()]}
|
|
onSelectProject={noop}
|
|
onAddProject={onAddProject}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Add Project"));
|
|
expect(onAddProject).toHaveBeenCalled();
|
|
});
|
|
|
|
it("displays correct stats in header", () => {
|
|
render(
|
|
<ProjectOverview
|
|
projects={[
|
|
makeProject({ id: "proj_1", status: "active" }),
|
|
makeProject({ id: "proj_2", status: "active" }),
|
|
makeProject({ id: "proj_3", status: "paused" }),
|
|
]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<ProjectOverview
|
|
projects={[
|
|
makeProject({ id: "proj_1", name: "Active Project", status: "active" }),
|
|
makeProject({ id: "proj_2", name: "Paused Project", status: "paused" }),
|
|
]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<ProjectOverview
|
|
projects={[
|
|
makeProject({ id: "proj_1", status: "active" }),
|
|
makeProject({ id: "proj_2", status: "active" }),
|
|
makeProject({ id: "proj_3", status: "paused" }),
|
|
makeProject({ id: "proj_4", status: "errored" }),
|
|
]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<ProjectOverview
|
|
projects={[
|
|
makeProject({ id: "proj_1", status: "active" }),
|
|
]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<ProjectOverview
|
|
projects={[
|
|
makeProject({ id: "proj_1", status: "active" }),
|
|
]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<ProjectOverview
|
|
projects={[]}
|
|
loading={true}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<ProjectOverview
|
|
projects={[project]}
|
|
onSelectProject={onSelectProject}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("project-card-proj_1"));
|
|
expect(onSelectProject).toHaveBeenCalledWith(project);
|
|
});
|
|
|
|
it("errored tab has special styling when errored projects exist", () => {
|
|
render(
|
|
<ProjectOverview
|
|
projects={[
|
|
makeProject({ id: "proj_1", status: "errored" }),
|
|
]}
|
|
onSelectProject={noop}
|
|
onAddProject={noop}
|
|
onPauseProject={noop}
|
|
onResumeProject={noop}
|
|
onRemoveProject={noop}
|
|
/>
|
|
);
|
|
|
|
// 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");
|
|
});
|
|
});
|