import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PiExtensionsManager } from "../PiExtensionsManager";
// Mock API module
const mockFetchPiSettings = vi.fn();
const mockUpdatePiSettings = vi.fn();
const mockInstallPiPackage = vi.fn();
const mockReinstallFusionPiPackage = vi.fn();
const mockFetchPiExtensions = vi.fn();
const mockUpdatePiExtensions = vi.fn();
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Package: ({ size }: { size?: number }) => (
Package-{size || 16}
),
Puzzle: ({ size }: { size?: number }) => (
Puzzle-{size || 14}
),
BookOpen: ({ size }: { size?: number }) => (
BookOpen-{size || 14}
),
FileText: ({ size }: { size?: number }) => (
FileText-{size || 14}
),
Palette: ({ size }: { size?: number }) => (
Palette-{size || 14}
),
Plus: ({ size }: { size?: number }) => (
Plus-{size || 14}
),
X: ({ size }: { size?: number }) => (
X-{size || 12}
),
ChevronDown: ({ size }: { size?: number }) => (
ChevronDown-{size || 14}
),
ChevronRight: ({ size }: { size?: number }) => (
ChevronRight-{size || 14}
),
RefreshCw: ({ size, className }: { size?: number; className?: string }) => (
RefreshCw-{size || 16}
),
Trash2: ({ size }: { size?: number }) => (
Trash2-{size || 14}
),
}));
vi.mock("../../api", () => ({
fetchPiSettings: (...args: unknown[]) => mockFetchPiSettings(...args),
updatePiSettings: (...args: unknown[]) => mockUpdatePiSettings(...args),
installPiPackage: (...args: unknown[]) => mockInstallPiPackage(...args),
reinstallFusionPiPackage: (...args: unknown[]) => mockReinstallFusionPiPackage(...args),
fetchPiExtensions: (...args: unknown[]) => mockFetchPiExtensions(...args),
updatePiExtensions: (...args: unknown[]) => mockUpdatePiExtensions(...args),
}));
const mockPiSettings = {
packages: [
"npm:pi-example",
"git:https://github.com/example/extension.git",
{ source: "npm:pi-with-filters", extensions: ["ext-a", "ext-b"], skills: ["skill-1"], prompts: [], themes: [] },
],
extensions: ["/path/to/extension"],
skills: ["/path/to/skill"],
prompts: ["/path/to/prompts"],
themes: ["/path/to/themes"],
};
const mockExtensions = [
{ id: "ext-1", name: "Example Extension", source: "fusion-global" as const, path: "/path/to/ext-1", enabled: true },
{ id: "ext-2", name: "Another Extension", source: "pi-project" as const, path: "/path/to/ext-2", enabled: false },
{ id: "ext-3", name: "Fusion Project Extension", source: "fusion-project" as const, path: "/path/to/ext-3", enabled: true },
{ id: "ext-4", name: "Package Extension", source: "package" as const, path: "/path/to/ext-4", enabled: true },
];
const mockExtensionsSettings = {
extensions: mockExtensions,
disabledIds: ["ext-2"],
settingsPath: "/path/to/settings",
};
const addToast = vi.fn();
describe("PiExtensionsManager", () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
// Default mock for fetchPiExtensions to return empty settings
mockFetchPiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
mockUpdatePiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
mockReinstallFusionPiPackage.mockResolvedValue({ success: true, source: "npm:@runfusion/fusion" });
});
describe("Rendering", () => {
it("renders Pi Extensions header", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render();
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Pi Extensions" })).toBeTruthy();
});
});
it("renders add package form", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render();
await waitFor(() => {
expect(screen.getByRole("textbox")).toBeTruthy();
expect(screen.getByRole("button", { name: /Add/i })).toBeTruthy();
});
});
it("renders reinstall Fusion skill action", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Reinstall Fusion skill" })).toBeTruthy();
});
});
it("renders package list with source badges", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render();
await waitFor(() => {
expect(screen.getAllByText("npm")).toHaveLength(2); // npm:pi-example and npm:pi-with-filters
expect(screen.getByText("git")).toBeTruthy();
expect(screen.getByText("pi-example")).toBeTruthy();
expect(screen.getByText(/github\.com\/example\/extension\.git/i)).toBeTruthy();
});
});
it("renders top-level resource sections", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render();
await waitFor(() => {
expect(screen.getByText("Extensions")).toBeTruthy();
expect(screen.getByText("Skills")).toBeTruthy();
expect(screen.getByText("Prompts")).toBeTruthy();
expect(screen.getByText("Themes")).toBeTruthy();
});
});
it("renders resource paths as removable tags", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render();
await waitFor(() => {
expect(screen.getByText("/path/to/extension")).toBeTruthy();
expect(screen.getByText("/path/to/skill")).toBeTruthy();
expect(screen.getByText("/path/to/prompts")).toBeTruthy();
expect(screen.getByText("/path/to/themes")).toBeTruthy();
});
});
});
describe("Loading state", () => {
it("shows loading state while fetching settings", async () => {
mockFetchPiSettings.mockImplementation(() => new Promise(() => {}));
render();
expect(screen.getByText("Loading Pi settings…")).toBeTruthy();
});
it("refresh button shows spinning class while loading", async () => {
mockFetchPiSettings.mockImplementation(() => new Promise(() => {}));
render();
const refreshIcons = screen.getAllByTestId("icon-refresh");
expect(refreshIcons.length).toBeGreaterThan(0);
refreshIcons.forEach((icon) => {
expect(icon).toHaveClass("spin");
});
});
});
describe("Empty state", () => {
it("shows empty state when no packages configured", async () => {
mockFetchPiSettings.mockResolvedValueOnce({ packages: [], extensions: [], skills: [], prompts: [], themes: [] });
render();
await waitFor(() => {
expect(screen.getByText("No packages configured.")).toBeTruthy();
});
expect(screen.getByText(/Add a package source above to get started/)).toBeTruthy();
});
it("shows Package icon in empty state", async () => {
mockFetchPiSettings.mockResolvedValueOnce({ packages: [], extensions: [], skills: [], prompts: [], themes: [] });
render();
await waitFor(() => {
const emptyState = screen.getByText("No packages configured.").closest(".empty-state");
expect(emptyState?.querySelector('[data-testid="icon-package"]')).toBeTruthy();
});
});
});
describe("Add package form", () => {
it("calls installPiPackage when Add button is clicked", async () => {
mockFetchPiSettings.mockResolvedValueOnce({ packages: [], extensions: [], skills: [], prompts: [], themes: [] });
mockInstallPiPackage.mockResolvedValueOnce({ success: true });
// Second call to loadSettings after install completes
mockFetchPiSettings.mockResolvedValueOnce({ packages: ["npm:new-package"], extensions: [], skills: [], prompts: [], themes: [] });
render();
await waitFor(() => {
expect(screen.getByText("No packages configured.")).toBeTruthy();
});
await userEvent.type(screen.getByRole("textbox"), "npm:new-package");
await userEvent.click(screen.getByRole("button", { name: /Add/i }));
await waitFor(() => {
expect(mockInstallPiPackage).toHaveBeenCalledWith("npm:new-package");
});
// Wait for refresh after install
await waitFor(() => {
expect(screen.queryByText("No packages configured.")).not.toBeTruthy();
});
});
it("shows error toast when install fails", async () => {
mockFetchPiSettings.mockResolvedValueOnce({ packages: [], extensions: [], skills: [], prompts: [], themes: [] });
mockInstallPiPackage.mockRejectedValueOnce(new Error("Install failed"));
render();
await waitFor(() => {
expect(screen.getByText("No packages configured.")).toBeTruthy();
});
await userEvent.type(screen.getByRole("textbox"), "npm:failing-package");
await userEvent.click(screen.getByRole("button", { name: /Add/i }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to install package: Install failed", "error");
});
});
it("shows loading state while installing", async () => {
mockFetchPiSettings.mockResolvedValueOnce({ packages: [], extensions: [], skills: [], prompts: [], themes: [] });
mockInstallPiPackage.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)));
render();
await waitFor(() => {
expect(screen.getByText("No packages configured.")).toBeTruthy();
});
await userEvent.type(screen.getByRole("textbox"), "npm:slow-package");
await userEvent.click(screen.getByRole("button", { name: /Add/i }));
expect(screen.getByRole("button", { name: /Installing/ })).toBeTruthy();
});
it("validates non-empty input — button disabled when empty", async () => {
mockFetchPiSettings.mockResolvedValueOnce({ packages: [], extensions: [], skills: [], prompts: [], themes: [] });
render();
await waitFor(() => {
expect(screen.getByText("No packages configured.")).toBeTruthy();
});
// Button should be disabled when input is empty
const addBtn = screen.getByRole("button", { name: /Add/i });
expect(addBtn).toBeDisabled();
expect(mockInstallPiPackage).not.toHaveBeenCalled();
});
});
describe("Reinstall Fusion action", () => {
it("calls reinstall API and refreshes settings and extensions on success", async () => {
mockFetchPiSettings.mockResolvedValue(mockPiSettings);
mockFetchPiExtensions.mockResolvedValue({ extensions: [], disabledIds: [], settingsPath: "" });
mockReinstallFusionPiPackage.mockResolvedValueOnce({ success: true, source: "npm:@runfusion/fusion" });
render();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Reinstall Fusion skill" })).toBeTruthy();
});
const settingsCallsBefore = mockFetchPiSettings.mock.calls.length;
const extensionCallsBefore = mockFetchPiExtensions.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: "Reinstall Fusion skill" }));
await waitFor(() => {
expect(mockReinstallFusionPiPackage).toHaveBeenCalledWith("proj-123");
});
await waitFor(() => {
expect(mockFetchPiSettings.mock.calls.length).toBeGreaterThan(settingsCallsBefore);
expect(mockFetchPiExtensions.mock.calls.length).toBeGreaterThan(extensionCallsBefore);
});
expect(addToast).toHaveBeenCalledWith("Fusion skill reinstalled successfully", "success");
});
it("shows error toast and resets loading state when reinstall fails", async () => {
mockFetchPiSettings.mockResolvedValue(mockPiSettings);
mockReinstallFusionPiPackage.mockRejectedValueOnce(new Error("Boom"));
render();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Reinstall Fusion skill" })).toBeTruthy();
});
await userEvent.click(screen.getByRole("button", { name: "Reinstall Fusion skill" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to reinstall Fusion skill: Boom", "error");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Reinstall Fusion skill" })).toBeTruthy();
});
});
});
describe("Remove package", () => {
it("calls updatePiSettings with package removed from list", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockUpdatePiSettings.mockResolvedValueOnce({ success: true });
mockFetchPiSettings.mockResolvedValueOnce({
packages: [mockPiSettings.packages[1], mockPiSettings.packages[2]],
extensions: [],
skills: [],
prompts: [],
themes: [],
});
render();
await waitFor(() => {
expect(screen.getByText("pi-example")).toBeTruthy();
});
// Find the remove button for the first package (npm:pi-example)
const removeButtons = screen.getAllByTestId("icon-trash");
await userEvent.click(removeButtons[0]);
await waitFor(() => {
expect(mockUpdatePiSettings).toHaveBeenCalledWith({
packages: [mockPiSettings.packages[1], mockPiSettings.packages[2]],
});
});
});
});
describe("Remove resource", () => {
it("calls updatePiSettings to remove an extension path", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockUpdatePiSettings.mockResolvedValueOnce({ success: true });
mockFetchPiSettings.mockResolvedValueOnce({
...mockPiSettings,
extensions: [],
});
render();
await waitFor(() => {
expect(screen.getByText("/path/to/extension")).toBeTruthy();
});
// Find the remove button for the extension
const removeButtons = screen.getAllByTestId("icon-x");
await userEvent.click(removeButtons[0]);
await waitFor(() => {
expect(mockUpdatePiSettings).toHaveBeenCalledWith({
extensions: [],
});
});
});
});
describe("Expand/collapse object-form packages", () => {
it("shows filter list when package with filters is expanded", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render();
await waitFor(() => {
expect(screen.getByText("pi-with-filters")).toBeTruthy();
});
// Find the expand button for pi-with-filters (the 3rd package, has filter-hint)
const filterHint = screen.getByText(/2 ext/);
const expandBtn = filterHint.closest(".pi-ext-package-card")?.querySelector(".pi-ext-expand-btn");
expect(expandBtn).toBeTruthy();
await userEvent.click(expandBtn!);
await waitFor(() => {
expect(screen.getByText("Extensions:", { exact: false })).toBeTruthy();
});
await waitFor(() => {
expect(screen.getByText("ext-a")).toBeTruthy();
});
expect(screen.getByText("ext-b")).toBeTruthy();
expect(screen.getByText("skill-1")).toBeTruthy();
});
it("hides filter list when collapsed", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render();
await waitFor(() => {
expect(screen.getByText("pi-with-filters")).toBeTruthy();
});
// Expand
const filterHint = screen.getByText(/2 ext/);
const expandBtn = filterHint.closest(".pi-ext-package-card")?.querySelector(".pi-ext-expand-btn");
await userEvent.click(expandBtn!);
await waitFor(() => {
expect(screen.getByText("ext-a")).toBeTruthy();
});
// Collapse
const chevronDown = screen.getAllByTestId("icon-chevron-down")[0];
await userEvent.click(chevronDown);
await waitFor(() => {
expect(screen.queryByText("ext-a")).not.toBeTruthy();
});
});
});
describe("Error handling", () => {
it("shows error toast when fetch fails", async () => {
mockFetchPiSettings.mockRejectedValueOnce(new Error("Network error"));
render();
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load Pi settings: Network error", "error");
});
});
it("shows error toast when update fails", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockUpdatePiSettings.mockRejectedValueOnce(new Error("Update failed"));
render();
await waitFor(() => {
expect(screen.getByText("pi-example")).toBeTruthy();
});
const removeButtons = screen.getAllByTestId("icon-trash");
await userEvent.click(removeButtons[0]);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to remove package: Update failed", "error");
});
});
});
describe("Discovered Extensions Section", () => {
it("renders discovered extensions section header", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render();
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Discovered Extensions" })).toBeTruthy();
});
});
it("renders extension entries from fetchPiExtensions", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render();
await waitFor(() => {
expect(screen.getByText("Example Extension")).toBeTruthy();
expect(screen.getByText("Another Extension")).toBeTruthy();
expect(screen.getByText("Fusion Project Extension")).toBeTruthy();
expect(screen.getByText("Package Extension")).toBeTruthy();
});
});
it("shows source badges for each extension", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render();
await waitFor(() => {
expect(screen.getByText("Fusion Global")).toBeTruthy();
expect(screen.getByText("Pi Project")).toBeTruthy();
expect(screen.getByText("Fusion Project")).toBeTruthy();
expect(screen.getByText("Package")).toBeTruthy();
});
});
it("displays extension paths", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render();
await waitFor(() => {
expect(screen.getByText("/path/to/ext-1")).toBeTruthy();
expect(screen.getByText("/path/to/ext-2")).toBeTruthy();
expect(screen.getByText("/path/to/ext-3")).toBeTruthy();
expect(screen.getByText("/path/to/ext-4")).toBeTruthy();
});
});
it("renders toggle switches for each extension", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render();
await waitFor(() => {
const toggles = screen.getAllByRole("checkbox");
expect(toggles).toHaveLength(4);
expect(toggles[0]).toBeChecked(); // ext-1 enabled
expect(toggles[1]).not.toBeChecked(); // ext-2 disabled
expect(toggles[2]).toBeChecked(); // ext-3 enabled
expect(toggles[3]).toBeChecked(); // ext-4 enabled
});
});
it("coexists with existing global package/path UI", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
render();
await waitFor(() => {
// Global package UI
expect(screen.getByText("pi-example")).toBeTruthy();
// Discovered extensions
expect(screen.getByText("Example Extension")).toBeTruthy();
});
});
it("forwards projectId to fetchPiExtensions", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render();
await waitFor(() => {
expect(mockFetchPiExtensions).toHaveBeenCalledWith("test-project-123");
});
});
it("toggles extension enabled state", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensionsSettings);
mockUpdatePiExtensions.mockResolvedValueOnce(undefined);
render();
await waitFor(() => {
const toggles = screen.getAllByRole("checkbox");
expect(toggles[0]).toBeChecked();
});
// Click the first toggle to disable
const toggles = screen.getAllByRole("checkbox");
await userEvent.click(toggles[0]);
await waitFor(() => {
expect(mockUpdatePiExtensions).toHaveBeenCalled();
});
});
it("shows error toast when fetch extensions fails", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockRejectedValueOnce(new Error("Failed to load"));
render();
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load extensions: Failed to load", "error");
});
});
it("shows empty state when no extensions found", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render();
await waitFor(() => {
expect(screen.getByText("No extensions discovered.")).toBeTruthy();
});
});
});
});