test(ui): add unit tests for 5 untested frontend components

- AntDLoadingSpinner: rendering, prop forwarding, icon styling
- MessageManager: static fallback, custom instance delegation
- claude_code_plugins/helpers: all pure utility functions (15 describe blocks, 55 tests)
- AgentSelector: fetch behavior, loading states, error handling, disabled state
- WorkerDropdown: conditional rendering, worker options, selection changes
This commit is contained in:
Ryan Crabbe
2026-03-23 22:03:10 -07:00
parent 3292d02aa4
commit e40f68aec4
5 changed files with 775 additions and 0 deletions
@@ -0,0 +1,134 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
// Mock the useWorker hook
const mockUseWorker = vi.fn();
vi.mock("@/hooks/useWorker", () => ({
useWorker: () => mockUseWorker(),
}));
// Mock antd Select
vi.mock("antd", () => ({
Select: ({ value, options, onChange, style, disabled, ...props }: any) => (
<select
data-testid="worker-select"
value={value}
style={style}
onChange={(e) => onChange?.(e.target.value)}
>
{options?.map((opt: any) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
),
}));
// Mock icon
vi.mock("@ant-design/icons", () => ({
CloudServerOutlined: () => <span data-testid="cloud-icon" />,
}));
import WorkerDropdown from "./WorkerDropdown";
describe("WorkerDropdown", () => {
const mockOnWorkerSwitch = vi.fn();
const workers = [
{ worker_id: "w1", name: "Worker 1" },
{ worker_id: "w2", name: "Worker 2" },
{ worker_id: "w3", name: "Worker 3" },
];
beforeEach(() => {
vi.clearAllMocks();
});
it("renders null when isControlPlane is false", () => {
mockUseWorker.mockReturnValue({
isControlPlane: false,
selectedWorker: workers[0],
workers,
});
const { container } = render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
expect(container).toBeEmptyDOMElement();
});
it("renders null when selectedWorker is null", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: null,
workers,
});
const { container } = render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
expect(container).toBeEmptyDOMElement();
});
it("renders the select when isControlPlane and selectedWorker exist", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
expect(screen.getByTestId("worker-select")).toBeInTheDocument();
});
it("renders all worker options", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
expect(screen.getByText("Worker 1")).toBeInTheDocument();
expect(screen.getByText("Worker 2")).toBeInTheDocument();
expect(screen.getByText("Worker 3")).toBeInTheDocument();
});
it("sets current worker as selected value", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[1],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
const select = screen.getByTestId("worker-select") as HTMLSelectElement;
expect(select.value).toBe("w2");
});
it("disables the currently selected worker in options", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
const options = screen.getAllByRole("option");
const selectedOption = options.find((opt) => (opt as HTMLOptionElement).value === "w1");
expect(selectedOption).toBeDisabled();
});
it("calls onWorkerSwitch when selection changes", async () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[0],
workers,
});
render(<WorkerDropdown onWorkerSwitch={mockOnWorkerSwitch} />);
const select = screen.getByTestId("worker-select");
const { default: userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
await user.selectOptions(select, "w2");
expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w2");
});
});
@@ -0,0 +1,144 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
// Mock networking module
const mockGetAgentsList = vi.fn();
vi.mock("../networking", () => ({
getAgentsList: (...args: any[]) => mockGetAgentsList(...args),
}));
// Mock antd Select
vi.mock("antd", () => {
const SelectComponent = ({ children, onChange, value, mode, placeholder, loading, disabled, ...props }: any) => (
<div data-testid="agent-select" data-loading={loading} data-disabled={disabled}>
<select
data-testid="select-input"
multiple={mode === "multiple"}
value={value || []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, (opt: any) => opt.value);
onChange?.(selected);
}}
disabled={disabled}
>
{children}
</select>
{loading && <span data-testid="loading-indicator">Loading</span>}
</div>
);
SelectComponent.Option = ({ children, value, ...props }: any) => (
<option value={value} {...props}>
{children}
</option>
);
return { Select: SelectComponent };
});
import AgentSelector from "./AgentSelector";
describe("AgentSelector", () => {
const defaultProps = {
onChange: vi.fn(),
accessToken: "test-token",
};
beforeEach(() => {
vi.clearAllMocks();
mockGetAgentsList.mockResolvedValue({
agents: [
{ agent_id: "agent-1", agent_name: "Agent One" },
{ agent_id: "agent-2", agent_name: "Agent Two", agent_access_groups: ["group-a", "group-b"] },
],
});
});
it("renders the selector", () => {
render(<AgentSelector {...defaultProps} />);
expect(screen.getByTestId("agent-select")).toBeInTheDocument();
});
it("fetches agents on mount with access token", async () => {
render(<AgentSelector {...defaultProps} />);
await waitFor(() => {
expect(mockGetAgentsList).toHaveBeenCalledWith("test-token");
});
});
it("does not fetch when accessToken is empty", () => {
render(<AgentSelector {...defaultProps} accessToken="" />);
expect(mockGetAgentsList).not.toHaveBeenCalled();
});
it("shows loading state while fetching", async () => {
// Keep the promise pending
let resolve: any;
mockGetAgentsList.mockReturnValue(new Promise((r) => { resolve = r; }));
render(<AgentSelector {...defaultProps} />);
expect(screen.getByTestId("agent-select")).toHaveAttribute("data-loading", "true");
// Resolve to clean up
resolve({ agents: [] });
await waitFor(() => {
expect(screen.getByTestId("agent-select")).toHaveAttribute("data-loading", "false");
});
});
it("renders agent options after fetch", async () => {
render(<AgentSelector {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Agent One")).toBeInTheDocument();
expect(screen.getByText("Agent Two")).toBeInTheDocument();
});
});
it("renders access group options with group prefix", async () => {
render(<AgentSelector {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("group-a")).toBeInTheDocument();
expect(screen.getByText("group-b")).toBeInTheDocument();
});
});
it("respects disabled prop", () => {
render(<AgentSelector {...defaultProps} disabled />);
expect(screen.getByTestId("agent-select")).toHaveAttribute("data-disabled", "true");
});
it("handles API error gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockGetAgentsList.mockRejectedValue(new Error("API error"));
render(<AgentSelector {...defaultProps} />);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith("Error fetching agents:", expect.any(Error));
});
consoleSpy.mockRestore();
});
it("passes value as flattened selectedValues", async () => {
render(
<AgentSelector
{...defaultProps}
value={{ agents: ["agent-1"], accessGroups: ["group-a"] }}
/>
);
await waitFor(() => {
const select = screen.getByTestId("select-input");
// The value should contain agent-1 and group:group-a
expect(select).toBeInTheDocument();
});
});
it("handles null response from API", async () => {
mockGetAgentsList.mockResolvedValue(null);
render(<AgentSelector {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("agent-select")).toHaveAttribute("data-loading", "false");
});
});
});
@@ -0,0 +1,329 @@
import { describe, expect, it } from "vitest";
import {
formatInstallCommand,
extractCategories,
validatePluginName,
getSourceDisplayText,
getSourceLink,
getCategoryBadgeColor,
formatDateString,
truncateText,
filterPluginsBySearch,
filterPluginsByCategory,
isValidSemanticVersion,
isValidEmail,
isValidUrl,
parseKeywords,
formatKeywords,
} from "./helpers";
import { MarketplacePluginEntry, PluginSource } from "./types";
describe("formatInstallCommand", () => {
it("formats github source with repo", () => {
const plugin = { name: "my-plugin", source: { source: "github" as const, repo: "org/repo" } };
expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add org/repo");
});
it("formats url source", () => {
const plugin = { name: "my-plugin", source: { source: "url" as const, url: "https://example.com/plugin" } };
expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add https://example.com/plugin");
});
it("falls back to plugin name when no repo or url", () => {
const plugin = { name: "my-plugin", source: { source: "github" as const } };
expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add my-plugin");
});
});
describe("extractCategories", () => {
it("returns All and Other for empty list", () => {
expect(extractCategories([])).toEqual(["All", "Other"]);
});
it("extracts and sorts unique categories", () => {
const plugins = [
{ category: "Development" },
{ category: "Analytics" },
{ category: "Development" },
];
expect(extractCategories(plugins)).toEqual(["All", "Analytics", "Development", "Other"]);
});
it("ignores empty/whitespace categories", () => {
const plugins = [{ category: "" }, { category: " " }, { category: "Tools" }];
expect(extractCategories(plugins)).toEqual(["All", "Tools", "Other"]);
});
it("handles undefined category", () => {
const plugins = [{ category: undefined }, { category: "Security" }];
expect(extractCategories(plugins)).toEqual(["All", "Security", "Other"]);
});
});
describe("validatePluginName", () => {
it("accepts valid kebab-case names", () => {
expect(validatePluginName("my-plugin")).toBe(true);
expect(validatePluginName("plugin123")).toBe(true);
expect(validatePluginName("a-b-c")).toBe(true);
});
it("rejects names with uppercase", () => {
expect(validatePluginName("MyPlugin")).toBe(false);
});
it("rejects names with spaces", () => {
expect(validatePluginName("my plugin")).toBe(false);
});
it("rejects empty/whitespace names", () => {
expect(validatePluginName("")).toBe(false);
expect(validatePluginName(" ")).toBe(false);
});
it("rejects names with special characters", () => {
expect(validatePluginName("my_plugin")).toBe(false);
expect(validatePluginName("my.plugin")).toBe(false);
});
});
describe("getSourceDisplayText", () => {
it("shows github repo", () => {
expect(getSourceDisplayText({ source: "github", repo: "org/repo" })).toBe("GitHub: org/repo");
});
it("shows url", () => {
expect(getSourceDisplayText({ source: "url", url: "https://example.com" })).toBe("https://example.com");
});
it("returns unknown for missing data", () => {
expect(getSourceDisplayText({ source: "github" })).toBe("Unknown source");
});
});
describe("getSourceLink", () => {
it("returns github link for github source", () => {
expect(getSourceLink({ source: "github", repo: "org/repo" })).toBe("https://github.com/org/repo");
});
it("returns url for url source", () => {
expect(getSourceLink({ source: "url", url: "https://example.com" })).toBe("https://example.com");
});
it("returns null when no repo or url", () => {
expect(getSourceLink({ source: "github" })).toBeNull();
});
});
describe("getCategoryBadgeColor", () => {
it("returns blue for development categories", () => {
expect(getCategoryBadgeColor("Development")).toBe("blue");
expect(getCategoryBadgeColor("dev-tools")).toBe("blue");
});
it("returns green for productivity categories", () => {
expect(getCategoryBadgeColor("Productivity")).toBe("green");
expect(getCategoryBadgeColor("Workflow")).toBe("green");
});
it("returns purple for learning categories", () => {
expect(getCategoryBadgeColor("Learning")).toBe("purple");
expect(getCategoryBadgeColor("Education")).toBe("purple");
});
it("returns red for security categories", () => {
expect(getCategoryBadgeColor("Security")).toBe("red");
expect(getCategoryBadgeColor("Safety")).toBe("red");
});
it("returns orange for data categories", () => {
expect(getCategoryBadgeColor("Data")).toBe("orange");
expect(getCategoryBadgeColor("Analytics")).toBe("orange");
});
it("returns yellow for integration categories", () => {
expect(getCategoryBadgeColor("Integration")).toBe("yellow");
expect(getCategoryBadgeColor("API")).toBe("yellow");
});
it("returns gray for unknown or undefined categories", () => {
expect(getCategoryBadgeColor("Unknown")).toBe("gray");
expect(getCategoryBadgeColor(undefined)).toBe("gray");
});
});
describe("formatDateString", () => {
it("formats valid date strings", () => {
const result = formatDateString("2024-01-15T12:00:00Z");
expect(result).toContain("2024");
expect(result).toContain("Jan");
expect(result).toContain("15");
});
it("returns N/A for undefined", () => {
expect(formatDateString(undefined)).toBe("N/A");
});
it("returns N/A for empty string", () => {
expect(formatDateString("")).toBe("N/A");
});
});
describe("truncateText", () => {
it("returns text unchanged if shorter than max", () => {
expect(truncateText("hello", 10)).toBe("hello");
});
it("truncates and adds ellipsis", () => {
expect(truncateText("hello world", 5)).toBe("hello...");
});
it("handles exact length", () => {
expect(truncateText("hello", 5)).toBe("hello");
});
it("handles empty text", () => {
expect(truncateText("", 5)).toBe("");
});
});
describe("filterPluginsBySearch", () => {
const plugins: MarketplacePluginEntry[] = [
{
name: "code-formatter",
source: { source: "github", repo: "org/formatter" },
description: "Formats code nicely",
keywords: ["format", "lint"],
},
{
name: "data-viewer",
source: { source: "github", repo: "org/viewer" },
description: "View data",
keywords: ["analytics"],
},
];
it("returns all plugins for empty search", () => {
expect(filterPluginsBySearch(plugins, "")).toEqual(plugins);
expect(filterPluginsBySearch(plugins, " ")).toEqual(plugins);
});
it("matches by name", () => {
expect(filterPluginsBySearch(plugins, "formatter")).toHaveLength(1);
expect(filterPluginsBySearch(plugins, "formatter")[0].name).toBe("code-formatter");
});
it("matches by description", () => {
expect(filterPluginsBySearch(plugins, "nicely")).toHaveLength(1);
});
it("matches by keyword", () => {
expect(filterPluginsBySearch(plugins, "analytics")).toHaveLength(1);
expect(filterPluginsBySearch(plugins, "analytics")[0].name).toBe("data-viewer");
});
it("is case insensitive", () => {
expect(filterPluginsBySearch(plugins, "FORMATTER")).toHaveLength(1);
});
});
describe("filterPluginsByCategory", () => {
const plugins: MarketplacePluginEntry[] = [
{ name: "a", source: { source: "github" }, category: "Dev" },
{ name: "b", source: { source: "github" }, category: "Security" },
{ name: "c", source: { source: "github" }, category: "" },
{ name: "d", source: { source: "github" } },
];
it("returns all plugins for 'All'", () => {
expect(filterPluginsByCategory(plugins, "All")).toEqual(plugins);
});
it("returns uncategorized plugins for 'Other'", () => {
const result = filterPluginsByCategory(plugins, "Other");
expect(result).toHaveLength(2);
expect(result.map((p) => p.name)).toEqual(["c", "d"]);
});
it("filters by specific category", () => {
const result = filterPluginsByCategory(plugins, "Dev");
expect(result).toHaveLength(1);
expect(result[0].name).toBe("a");
});
});
describe("isValidSemanticVersion", () => {
it("accepts valid semver", () => {
expect(isValidSemanticVersion("1.0.0")).toBe(true);
expect(isValidSemanticVersion("0.1.0-alpha")).toBe(true);
expect(isValidSemanticVersion("2.3.4+build.1")).toBe(true);
});
it("rejects invalid semver", () => {
expect(isValidSemanticVersion("1.0")).toBe(false);
expect(isValidSemanticVersion("abc")).toBe(false);
});
it("returns true for undefined (optional)", () => {
expect(isValidSemanticVersion(undefined)).toBe(true);
});
});
describe("isValidEmail", () => {
it("accepts valid emails", () => {
expect(isValidEmail("user@example.com")).toBe(true);
});
it("rejects invalid emails", () => {
expect(isValidEmail("not-an-email")).toBe(false);
expect(isValidEmail("@example.com")).toBe(false);
});
it("returns true for undefined (optional)", () => {
expect(isValidEmail(undefined)).toBe(true);
});
});
describe("isValidUrl", () => {
it("accepts valid urls", () => {
expect(isValidUrl("https://example.com")).toBe(true);
expect(isValidUrl("http://localhost:3000")).toBe(true);
});
it("rejects invalid urls", () => {
expect(isValidUrl("not a url")).toBe(false);
});
it("returns true for undefined (optional)", () => {
expect(isValidUrl(undefined)).toBe(true);
});
});
describe("parseKeywords", () => {
it("splits comma-separated keywords", () => {
expect(parseKeywords("a, b, c")).toEqual(["a", "b", "c"]);
});
it("trims whitespace", () => {
expect(parseKeywords(" foo , bar ")).toEqual(["foo", "bar"]);
});
it("filters empty entries", () => {
expect(parseKeywords("a,,b,")).toEqual(["a", "b"]);
});
it("returns empty array for empty string", () => {
expect(parseKeywords("")).toEqual([]);
expect(parseKeywords(" ")).toEqual([]);
});
});
describe("formatKeywords", () => {
it("joins keywords with comma and space", () => {
expect(formatKeywords(["a", "b", "c"])).toBe("a, b, c");
});
it("returns empty string for empty/undefined array", () => {
expect(formatKeywords([])).toBe("");
expect(formatKeywords(undefined)).toBe("");
});
});
@@ -0,0 +1,107 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// Use vi.hoisted so the mock object is available when vi.mock is hoisted
const mockStaticMessage = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
destroy: vi.fn(),
}));
vi.mock("antd", () => ({
message: mockStaticMessage,
}));
import MessageManager, { setMessageInstance } from "./message_manager";
describe("MessageManager", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("when no instance is set (falls back to static message)", () => {
it("delegates success to static message", () => {
MessageManager.success("done!");
expect(mockStaticMessage.success).toHaveBeenCalledWith("done!", undefined);
});
it("delegates error to static message", () => {
MessageManager.error("failed!", 5);
expect(mockStaticMessage.error).toHaveBeenCalledWith("failed!", 5);
});
it("delegates warning to static message", () => {
MessageManager.warning("watch out");
expect(mockStaticMessage.warning).toHaveBeenCalledWith("watch out", undefined);
});
it("delegates info to static message", () => {
MessageManager.info("fyi");
expect(mockStaticMessage.info).toHaveBeenCalledWith("fyi", undefined);
});
it("delegates loading to static message", () => {
MessageManager.loading("loading...", 3);
expect(mockStaticMessage.loading).toHaveBeenCalledWith("loading...", 3);
});
it("delegates destroy to static message", () => {
MessageManager.destroy();
expect(mockStaticMessage.destroy).toHaveBeenCalled();
});
});
describe("when a custom instance is set", () => {
const mockInstance = {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
destroy: vi.fn(),
open: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
setMessageInstance(mockInstance as any);
});
it("delegates success to custom instance", () => {
MessageManager.success("done!");
expect(mockInstance.success).toHaveBeenCalledWith("done!", undefined);
expect(mockStaticMessage.success).not.toHaveBeenCalled();
});
it("delegates error with duration to custom instance", () => {
MessageManager.error("failed!", 5);
expect(mockInstance.error).toHaveBeenCalledWith("failed!", 5);
expect(mockStaticMessage.error).not.toHaveBeenCalled();
});
it("delegates warning to custom instance", () => {
MessageManager.warning("watch out");
expect(mockInstance.warning).toHaveBeenCalledWith("watch out", undefined);
});
it("delegates info to custom instance", () => {
MessageManager.info("fyi", 2);
expect(mockInstance.info).toHaveBeenCalledWith("fyi", 2);
});
it("delegates loading to custom instance and returns result", () => {
const mockReturn = { then: vi.fn() };
mockInstance.loading.mockReturnValue(mockReturn);
const result = MessageManager.loading("loading...", 3);
expect(mockInstance.loading).toHaveBeenCalledWith("loading...", 3);
expect(result).toBe(mockReturn);
});
it("delegates destroy to custom instance", () => {
MessageManager.destroy();
expect(mockInstance.destroy).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,61 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Mock antd Spin component
vi.mock("antd", () => ({
Spin: ({ indicator, size, ...props }: any) => (
<div data-testid="spin" data-size={size} {...props}>
{indicator}
</div>
),
}));
// Mock the icon
vi.mock("@ant-design/icons", () => ({
LoadingOutlined: ({ style, spin, ...props }: any) => (
<span
data-testid="loading-icon"
data-spin={spin}
style={style}
{...props}
/>
),
}));
import { AntDLoadingSpinner } from "./AntDLoadingSpinner";
describe("AntDLoadingSpinner", () => {
it("renders without props", () => {
render(<AntDLoadingSpinner />);
expect(screen.getByTestId("spin")).toBeInTheDocument();
expect(screen.getByTestId("loading-icon")).toBeInTheDocument();
});
it("passes size prop to Spin", () => {
render(<AntDLoadingSpinner size="large" />);
expect(screen.getByTestId("spin")).toHaveAttribute("data-size", "large");
});
it("passes small size to Spin", () => {
render(<AntDLoadingSpinner size="small" />);
expect(screen.getByTestId("spin")).toHaveAttribute("data-size", "small");
});
it("applies custom fontSize to the icon", () => {
render(<AntDLoadingSpinner fontSize={32} />);
const icon = screen.getByTestId("loading-icon");
expect(icon).toHaveStyle({ fontSize: "32px" });
});
it("does not set style when fontSize is not provided", () => {
render(<AntDLoadingSpinner />);
const icon = screen.getByTestId("loading-icon");
expect(icon.style.fontSize).toBe("");
});
it("sets spin attribute on icon", () => {
render(<AntDLoadingSpinner />);
const icon = screen.getByTestId("loading-icon");
expect(icon).toHaveAttribute("data-spin", "true");
});
});