Files
fusion/packages/dashboard/app/components/__tests__/ActivityFeed.test.tsx
gsxdsm e75b0ce5a7 fix(FN-873): replace hardcoded error color with theme-aware CSS variable in ActivityFeed
- Replace var(--error) with var(--color-error) in ActivityFeed TYPE_CONFIG for consistency with theme system
- Add unit tests for theme-aware color variables in TYPE_CONFIG
- Add integration test verifying ActivityFeed renders error colors correctly
- Document theme-aware styling note in dashboard README Activity Log section
2026-04-04 08:11:43 -07:00

212 lines
7.3 KiB
TypeScript

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(":");
});
it("uses theme tokens for event type icon colors", () => {
const entries: ActivityFeedEntry[] = [
makeEntry({ id: "1", type: "task:deleted" }),
makeEntry({ id: "2", type: "task:failed" }),
];
const { container } = render(<ActivityFeed entries={entries} />);
// Verify that deleted and failed events use --color-error (not undefined --error)
const icons = container.querySelectorAll(".activity-feed-icon");
expect(icons.length).toBeGreaterThanOrEqual(2);
for (const icon of icons) {
const style = (icon as HTMLElement).style;
const color = style.color || style.getPropertyValue("color");
// Should use var(--color-error), NOT var(--error) which is undefined
expect(color).toContain("var(--color-error)");
expect(color).not.toContain("var(--error)");
}
});
});