fix(KB-503): remove duplicate getDefaultProject function
- Import getDefaultProject from project-context.js instead of duplicating - Remove local getDefaultProject implementation in project.ts - All 6 tests still passing
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ActivityFeed } from "../ActivityFeed";
|
||||
import type { ActivityFeedEntry } from "../../api";
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
GitPullRequest: () => <span data-testid="pr-icon">PR</span>,
|
||||
GitMerge: () => <span data-testid="merge-icon">Merge</span>,
|
||||
CheckCircle: () => <span data-testid="check-icon">✓</span>,
|
||||
XCircle: () => <span data-testid="x-icon">✗</span>,
|
||||
Plus: () => <span data-testid="plus-icon">+</span>,
|
||||
ArrowRightLeft: () => <span data-testid="arrow-icon">→</span>,
|
||||
Settings: () => <span data-testid="settings-icon">⚙</span>,
|
||||
AlertTriangle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
};
|
||||
});
|
||||
|
||||
function makeEntry(overrides: Partial<ActivityFeedEntry> = {}): ActivityFeedEntry {
|
||||
return {
|
||||
id: "entry_001",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "task:created",
|
||||
projectId: "proj_abc123",
|
||||
projectName: "Test Project",
|
||||
details: "Task created",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ActivityFeed", () => {
|
||||
it("renders empty state when no entries", () => {
|
||||
render(<ActivityFeed entries={[]} />);
|
||||
|
||||
expect(screen.getByText("No recent activity")).toBeDefined();
|
||||
expect(screen.getByText(/Activity will appear here/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders custom empty message", () => {
|
||||
render(<ActivityFeed entries={[]} emptyMessage="Custom empty message" />);
|
||||
|
||||
expect(screen.getByText("Custom empty message")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders loading state", () => {
|
||||
const { container } = render(<ActivityFeed entries={[]} isLoading={true} />);
|
||||
|
||||
expect(container.querySelector(".activity-feed-loading")).toBeDefined();
|
||||
expect(container.querySelector(".activity-feed-skeleton")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders error state", () => {
|
||||
render(<ActivityFeed entries={[]} error="Failed to load activity" />);
|
||||
|
||||
expect(screen.getByText("Failed to load activity")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders activity entries", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ id: "entry_001", type: "task:created", details: "Created FN-001" }),
|
||||
makeEntry({ id: "entry_002", type: "task:moved", details: "Moved FN-002 to in-progress" }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
expect(screen.getByText("Created")).toBeDefined();
|
||||
expect(screen.getByText("Moved")).toBeDefined();
|
||||
expect(screen.getByText("Created FN-001")).toBeDefined();
|
||||
expect(screen.getByText("Moved FN-002 to in-progress")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows project names when provided", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ projectId: "proj_abc123", projectName: "Project Alpha" }),
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityFeed
|
||||
entries={entries}
|
||||
projectNames={{ "proj_abc123": "Project Alpha" }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project Alpha")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays task ID when available", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ taskId: "FN-042", taskTitle: "Fix bug" }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
expect(screen.getByText("FN-042")).toBeDefined();
|
||||
expect(screen.getByText("Fix bug")).toBeDefined();
|
||||
});
|
||||
|
||||
it("groups entries by date", () => {
|
||||
const today = new Date().toISOString();
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString();
|
||||
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ id: "entry_001", timestamp: today }),
|
||||
makeEntry({ id: "entry_002", timestamp: yesterday }),
|
||||
];
|
||||
|
||||
const { container } = render(<ActivityFeed entries={entries} />);
|
||||
|
||||
const groups = container.querySelectorAll(".activity-feed-group");
|
||||
expect(groups.length).toBe(2);
|
||||
});
|
||||
|
||||
it("shows relative time for recent entries", () => {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60000).toISOString();
|
||||
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ timestamp: fiveMinutesAgo }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
expect(screen.getByText("5m ago")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders different event types with correct labels", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ id: "1", type: "task:created" }),
|
||||
makeEntry({ id: "2", type: "task:moved" }),
|
||||
makeEntry({ id: "3", type: "task:updated" }),
|
||||
makeEntry({ id: "4", type: "task:deleted" }),
|
||||
makeEntry({ id: "5", type: "task:merged" }),
|
||||
makeEntry({ id: "6", type: "task:failed" }),
|
||||
makeEntry({ id: "7", type: "settings:updated" }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
expect(screen.getByText("Created")).toBeDefined();
|
||||
expect(screen.getByText("Moved")).toBeDefined();
|
||||
expect(screen.getByText("Updated")).toBeDefined();
|
||||
expect(screen.getByText("Deleted")).toBeDefined();
|
||||
expect(screen.getByText("Merged")).toBeDefined();
|
||||
expect(screen.getByText("Failed")).toBeDefined();
|
||||
expect(screen.getByText("Settings")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders with data-type attribute for styling", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ type: "task:created" }),
|
||||
];
|
||||
|
||||
const { container } = render(<ActivityFeed entries={entries} />);
|
||||
|
||||
const item = container.querySelector('[data-type="task:created"]');
|
||||
expect(item).toBeDefined();
|
||||
});
|
||||
|
||||
it("truncates long task titles", () => {
|
||||
const longTitle = "A".repeat(200);
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ taskTitle: longTitle }),
|
||||
];
|
||||
|
||||
const { container } = render(<ActivityFeed entries={entries} />);
|
||||
|
||||
const titleEl = container.querySelector(".activity-feed-task-title");
|
||||
expect(titleEl).toBeDefined();
|
||||
expect(titleEl?.getAttribute("title")).toBe(longTitle);
|
||||
});
|
||||
|
||||
it("shows full timestamp on hover via title attribute", () => {
|
||||
// Use a recent timestamp so it shows "ago" format
|
||||
const recentTime = new Date(Date.now() - 5 * 60000).toISOString();
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ timestamp: recentTime }),
|
||||
];
|
||||
|
||||
render(<ActivityFeed entries={entries} />);
|
||||
|
||||
// Should show relative time like "5m ago"
|
||||
const timeEl = screen.getByText(/ago/);
|
||||
expect(timeEl).toBeDefined();
|
||||
// Title attribute should have full timestamp
|
||||
expect(timeEl.getAttribute("title")).toContain(":");
|
||||
});
|
||||
});
|
||||
432
packages/dashboard/app/components/__tests__/ProjectCard.test.tsx
Normal file
432
packages/dashboard/app/components/__tests__/ProjectCard.test.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ProjectCard } from "../ProjectCard";
|
||||
import type { RegisteredProject, ProjectHealth } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
||||
vi.mock("lucide-react", () => ({
|
||||
Play: () => <span data-testid="play-icon">▶</span>,
|
||||
Pause: () => <span data-testid="pause-icon">⏸</span>,
|
||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||
MoreHorizontal: () => null,
|
||||
Trash2: () => <span data-testid="trash-icon">🗑</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
ArrowRight: () => <span data-testid="arrow-icon">→</span>,
|
||||
}));
|
||||
|
||||
function makeProject(overrides: Partial<RegisteredProject> = {}): RegisteredProject {
|
||||
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",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHealth(overrides: Partial<ProjectHealth> = {}): ProjectHealth {
|
||||
return {
|
||||
projectId: "proj_abc123",
|
||||
status: "active",
|
||||
activeTaskCount: 5,
|
||||
inFlightAgentCount: 2,
|
||||
totalTasksCompleted: 100,
|
||||
totalTasksFailed: 3,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("ProjectCard", () => {
|
||||
it("renders project name and path", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ name: "My Project", path: "/path/to/project" })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("My Project")).toBeDefined();
|
||||
expect(screen.getByText("/path/to/project")).toBeDefined();
|
||||
});
|
||||
|
||||
it("truncates long paths", () => {
|
||||
const longPath = "/very/long/path/to/the/project/directory/that/needs/truncation";
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ path: longPath })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated version
|
||||
const pathElement = screen.getByText(/\/very\/long\/.*\/truncation/);
|
||||
expect(pathElement).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders active status badge", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "active" })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Active")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders paused status badge", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "paused" })}
|
||||
health={makeHealth({ status: "paused" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Paused")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders errored status badge", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "errored" })}
|
||||
health={makeHealth({ status: "errored" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Error")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders initializing status badge with spinner", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "initializing" })}
|
||||
health={makeHealth({ status: "initializing" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Initializing")).toBeDefined();
|
||||
expect(screen.getByTestId("loader-icon")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays health metrics", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject()}
|
||||
health={makeHealth({
|
||||
activeTaskCount: 10,
|
||||
inFlightAgentCount: 3,
|
||||
totalTasksCompleted: 250,
|
||||
})}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("10")).toBeDefined();
|
||||
expect(screen.getByText("Active Tasks")).toBeDefined();
|
||||
expect(screen.getByText("3")).toBeDefined();
|
||||
expect(screen.getByText("Agents")).toBeDefined();
|
||||
expect(screen.getByText("250")).toBeDefined();
|
||||
expect(screen.getByText("Completed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'No health data' when health is null", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject()}
|
||||
health={null}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("No health data available")).toBeDefined();
|
||||
});
|
||||
|
||||
it("formats relative time for last activity", () => {
|
||||
const recentTime = new Date(Date.now() - 5 * 60000).toISOString(); // 5 minutes ago
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ lastActivityAt: recentTime })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("5m ago")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Never' when no last activity", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ lastActivityAt: undefined })}
|
||||
health={makeHealth({ lastActivityAt: undefined })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Never")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onSelect when card is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
const project = makeProject();
|
||||
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth()}
|
||||
onSelect={onSelect}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector('[data-project-id="proj_abc123"]');
|
||||
expect(card).not.toBeNull();
|
||||
fireEvent.click(card!);
|
||||
expect(onSelect).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("calls onSelect when Enter key is pressed", () => {
|
||||
const onSelect = vi.fn();
|
||||
const project = makeProject();
|
||||
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth()}
|
||||
onSelect={onSelect}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector('[data-project-id="proj_abc123"]');
|
||||
expect(card).not.toBeNull();
|
||||
fireEvent.keyDown(card!, { key: "Enter" });
|
||||
expect(onSelect).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("calls onPause when pause button is clicked", () => {
|
||||
const onPause = vi.fn();
|
||||
const project = makeProject({ status: "active" });
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={onPause}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Pause project"));
|
||||
expect(onPause).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("calls onResume when resume button is clicked", () => {
|
||||
const onResume = vi.fn();
|
||||
const project = makeProject({ status: "paused" });
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth({ status: "paused" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={onResume}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Resume project"));
|
||||
expect(onResume).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("calls onRemove when remove button is clicked", () => {
|
||||
const onRemove = vi.fn();
|
||||
const project = makeProject();
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={project}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Remove project"));
|
||||
expect(onRemove).toHaveBeenCalledWith(project);
|
||||
});
|
||||
|
||||
it("disables pause button when initializing", () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "initializing" })}
|
||||
health={makeHealth({ status: "initializing" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the pause button by its title attribute
|
||||
const pauseButton = container.querySelector('button[title="Cannot pause while initializing"]');
|
||||
expect(pauseButton).not.toBeNull();
|
||||
expect(pauseButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables all buttons when isLoading is true", () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "active" })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Pause project")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Open project")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Remove project")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("adds loading class when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={makeProject()}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector(".project-card-loading")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds errored class when project status is errored", () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "errored" })}
|
||||
health={makeHealth({ status: "errored" })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector(".project-card-errored")).toBeDefined();
|
||||
});
|
||||
|
||||
it("prevents event bubbling when clicking action buttons", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onPause = vi.fn();
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ status: "active" })}
|
||||
health={makeHealth()}
|
||||
onSelect={onSelect}
|
||||
onPause={onPause}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Pause project"));
|
||||
expect(onPause).toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders with data-project-id attribute", () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={makeProject({ id: "proj_test123" })}
|
||||
health={makeHealth()}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector("[data-project-id='proj_test123']");
|
||||
expect(card).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses health lastActivityAt as fallback when project lastActivityAt is undefined", () => {
|
||||
const healthTime = "2026-01-15T12:00:00.000Z";
|
||||
render(
|
||||
<ProjectCard
|
||||
project={makeProject({ lastActivityAt: undefined })}
|
||||
health={makeHealth({ lastActivityAt: healthTime })}
|
||||
onSelect={noop}
|
||||
onPause={noop}
|
||||
onResume={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show the health's lastActivityAt formatted (shows as date since it's > 7 days ago)
|
||||
expect(screen.getByText(/Last activity:/)).toBeDefined();
|
||||
// The formatted date should show (1/15/2026 format in US locale)
|
||||
expect(screen.getByText(/1\//)).toBeDefined();
|
||||
});
|
||||
});
|
||||
284
packages/dashboard/app/components/__tests__/SetupWizard.test.tsx
Normal file
284
packages/dashboard/app/components/__tests__/SetupWizard.test.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SetupWizard } from "../SetupWizard";
|
||||
import type { ProjectInfo, ProjectCreateInput } from "../../api";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
X: () => <span data-testid="close-icon">×</span>,
|
||||
ChevronRight: () => <span data-testid="next-icon">→</span>,
|
||||
ChevronLeft: () => <span data-testid="back-icon">←</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
Check: () => <span data-testid="check-icon">✓</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("SetupWizard", () => {
|
||||
it("does not render when isOpen is false", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Add New Project")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders when isOpen is true", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Add New Project")).toBeDefined();
|
||||
});
|
||||
|
||||
it("starts at directory step", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows step indicator with 5 steps", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Directory")).toBeDefined();
|
||||
expect(screen.getByText("Name")).toBeDefined();
|
||||
expect(screen.getByText("Mode")).toBeDefined();
|
||||
expect(screen.getByText("Validate")).toBeDefined();
|
||||
expect(screen.getByText("Confirm")).toBeDefined();
|
||||
});
|
||||
|
||||
it("disables Next button when directory is empty", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Next button when directory is filled", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("navigates to next step when Next is clicked", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Find the primary button (Next) in the actions area
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
expect(screen.getByText("Project Name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("auto-suggests name from directory path", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/my-awesome-project" } });
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("My Project") as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("my-awesome-project");
|
||||
});
|
||||
|
||||
it("allows navigation back to previous step", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Go to step 2
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
// Go back
|
||||
const backButton = screen.getByRole("button", { name: /Back/i });
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows isolation mode options", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Navigate to step 3 (isolation)
|
||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Go to name step
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
// Go to isolation step
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
expect(screen.getByText("In-Process (Default)")).toBeDefined();
|
||||
expect(screen.getByText("Child Process (Isolated)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onClose when Cancel is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: /Cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when close icon is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits project data when created", async () => {
|
||||
const mockRegisterProject = vi.fn().mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "My Project",
|
||||
path: "/home/user/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
} as ProjectInfo);
|
||||
|
||||
const onProjectCreated = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={onProjectCreated}
|
||||
onRegisterProject={mockRegisterProject}
|
||||
/>
|
||||
);
|
||||
|
||||
// Fill directory
|
||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
||||
|
||||
// The wizard should be in directory step with a Next button
|
||||
expect(screen.getByRole("button", { name: /Next/i })).toBeDefined();
|
||||
|
||||
// Note: Full wizard flow testing would require more complex setup
|
||||
// including mocking the validation API call
|
||||
});
|
||||
|
||||
it("resets state when reopened", () => {
|
||||
const { rerender } = render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Fill some data
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Close and reopen
|
||||
rerender(
|
||||
<SetupWizard
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
rerender(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should be back at step 1 with empty fields
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
const newInput = screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement;
|
||||
expect(newInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user