Files
fusion/packages/dashboard/app/components/__tests__/PiExtensionsManager.test.tsx
Fusion 3629dea3c2 feat(FN-2035): introduce PiExtensionsManager for settings UI parity
- Create a dedicated PiExtensionsManager component mirroring PluginManager interaction patterns
- Replace inline Pi extensions rendering in SettingsModal with the new manager component
- Add PiExtensionsManager styling in styles.css to match existing plugin manager layout and states
- Add component test coverage for PiExtensionsManager and update SettingsModal mocks/assertions
- Add a changeset for the pi-extensions UI parity release
2026-04-17 21:54:44 -07:00

338 lines
12 KiB
TypeScript

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";
import type { PiExtensionSettings } from "../../api";
// Mock API module - must be declared before import
const mockFetchPiExtensions = vi.fn();
const mockUpdatePiExtensions = vi.fn();
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Package: ({ size }: { size?: number }) => (
<span data-testid="icon-package">Package-{size || 16}</span>
),
RefreshCw: ({ size, className }: { size?: number; className?: string }) => (
<span data-testid="icon-refresh" className={className}>
RefreshCw-{size || 16}
</span>
),
}));
vi.mock("../../api", () => ({
fetchPiExtensions: (...args: unknown[]) => mockFetchPiExtensions(...args),
updatePiExtensions: (...args: unknown[]) => mockUpdatePiExtensions(...args),
}));
const mockExtensions: PiExtensionSettings = {
extensions: [
{
id: "ext-1",
name: "Test Extension 1",
path: "/path/to/ext-1",
source: "fusion-global",
enabled: true,
},
{
id: "ext-2",
name: "Test Extension 2",
path: "/path/to/ext-2",
source: "pi-global",
enabled: false,
},
{
id: "ext-3",
name: "Test Extension 3",
path: "/path/to/ext-3",
source: "fusion-project",
enabled: true,
},
{
id: "ext-4",
name: "Test Extension 4",
path: "/path/to/ext-4",
source: "pi-project",
enabled: false,
},
],
disabledIds: ["ext-2", "ext-4"],
settingsPath: "/path/to/settings.json",
};
const addToast = vi.fn();
describe("PiExtensionsManager", () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
describe("Rendering", () => {
it("renders Pi Extensions header", async () => {
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => expect(screen.getByRole("heading", { name: /Pi Extensions/i })).toBeTruthy());
});
it("renders extension list with names and toggle switches", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
render(<PiExtensionsManager addToast={addToast} />);
// Both extension names should be visible
await waitFor(() => {
expect(screen.getByText("Test Extension 1")).toBeTruthy();
expect(screen.getByText("Test Extension 2")).toBeTruthy();
});
// Both toggle switches should be present
const toggles = screen.getAllByRole("checkbox");
expect(toggles).toHaveLength(4);
// Enabled extension should be checked, disabled should be unchecked
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]).not.toBeChecked(); // ext-4 disabled
});
it("shows source badges with correct labels", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Fusion Global")).toBeTruthy();
expect(screen.getByText("Pi Global")).toBeTruthy();
expect(screen.getByText("Fusion Project")).toBeTruthy();
expect(screen.getByText("Pi Project")).toBeTruthy();
});
});
it("shows source badges with correct CSS classes", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
const fusionGlobalBadge = screen.getByText("Fusion Global").closest(".pi-ext-source-badge");
expect(fusionGlobalBadge).toHaveClass("pi-ext-source-badge--global");
const piGlobalBadge = screen.getByText("Pi Global").closest(".pi-ext-source-badge");
expect(piGlobalBadge).toHaveClass("pi-ext-source-badge--global");
const fusionProjectBadge = screen.getByText("Fusion Project").closest(".pi-ext-source-badge");
expect(fusionProjectBadge).toHaveClass("pi-ext-source-badge--project");
const piProjectBadge = screen.getByText("Pi Project").closest(".pi-ext-source-badge");
expect(piProjectBadge).toHaveClass("pi-ext-source-badge--project");
});
});
it("shows extension paths", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
render(<PiExtensionsManager addToast={addToast} />);
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 description text explaining pi extensions", async () => {
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText(/Choose which project and global Pi extensions/)).toBeTruthy();
});
});
it("passes projectId to fetchPiExtensions", async () => {
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render(<PiExtensionsManager addToast={addToast} projectId="proj-123" />);
await waitFor(() => {
expect(mockFetchPiExtensions).toHaveBeenCalledWith("proj-123");
});
});
});
describe("Loading state", () => {
it("shows loading state while fetching extensions", async () => {
mockFetchPiExtensions.mockImplementation(() => new Promise(() => {}));
render(<PiExtensionsManager addToast={addToast} />);
expect(screen.getByText("Loading Pi extensions…")).toBeTruthy();
});
it("refresh button shows spinning class while loading", async () => {
mockFetchPiExtensions.mockImplementation(() => new Promise(() => {}));
render(<PiExtensionsManager addToast={addToast} />);
const refreshBtn = screen.getByTestId("icon-refresh");
expect(refreshBtn).toHaveClass("spin");
});
});
describe("Empty state", () => {
it("shows empty state when no extensions found", async () => {
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("No Pi extensions found.")).toBeTruthy();
});
expect(screen.getByText(/Extensions are discovered from ~\/\.fusion\/agent/)).toBeTruthy();
});
it("shows Package icon in empty state", async () => {
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("icon-package")).toBeTruthy();
});
});
});
describe("Toggle interactions", () => {
it("toggles extension on - enables disabled extension", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
mockUpdatePiExtensions.mockResolvedValueOnce({
...mockExtensions,
extensions: mockExtensions.extensions.map((ext) =>
ext.id === "ext-2" ? { ...ext, enabled: true } : ext
),
disabledIds: ["ext-4"],
});
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
const toggles = screen.getAllByRole("checkbox");
expect(toggles[1]).not.toBeChecked();
});
const toggles = screen.getAllByRole("checkbox");
await userEvent.click(toggles[1]); // ext-2 is disabled, click to enable
await waitFor(() => {
expect(mockUpdatePiExtensions).toHaveBeenCalledWith(["ext-4"], undefined);
});
expect(addToast).toHaveBeenCalledWith("Pi extension settings saved", "success");
});
it("toggles extension off - disables enabled extension", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
mockUpdatePiExtensions.mockResolvedValueOnce({
...mockExtensions,
extensions: mockExtensions.extensions.map((ext) =>
ext.id === "ext-1" ? { ...ext, enabled: false } : ext
),
disabledIds: ["ext-2", "ext-4", "ext-1"],
});
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
const toggles = screen.getAllByRole("checkbox");
expect(toggles[0]).toBeChecked();
});
const toggles = screen.getAllByRole("checkbox");
await userEvent.click(toggles[0]); // ext-1 is enabled, click to disable
await waitFor(() => {
expect(mockUpdatePiExtensions).toHaveBeenCalledWith(["ext-2", "ext-4", "ext-1"], undefined);
});
expect(addToast).toHaveBeenCalledWith("Pi extension settings saved", "success");
});
it("disables toggle while saving", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
mockUpdatePiExtensions.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockExtensions), 100))
);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
const toggles = screen.getAllByRole("checkbox");
expect(toggles[0]).toBeChecked();
});
const toggles = screen.getAllByRole("checkbox");
await userEvent.click(toggles[0]);
// Toggle should be disabled while saving
expect(toggles[0]).toBeDisabled();
});
});
describe("Refresh button", () => {
it("refresh button triggers reload", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getAllByRole("checkbox")).toHaveLength(4);
});
mockFetchPiExtensions.mockResolvedValueOnce({ extensions: [], disabledIds: [], settingsPath: "" });
const refreshBtn = screen.getByTestId("icon-refresh").closest("button");
await userEvent.click(refreshBtn!);
await waitFor(() => {
expect(mockFetchPiExtensions).toHaveBeenCalledTimes(2);
});
});
});
describe("Error handling", () => {
it("shows error toast when fetch fails", async () => {
mockFetchPiExtensions.mockRejectedValueOnce(new Error("Network error"));
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load Pi extensions: Network error", "error");
});
});
it("shows error toast when update fails", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
mockUpdatePiExtensions.mockRejectedValueOnce(new Error("Update failed"));
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getAllByRole("checkbox")).toHaveLength(4);
});
const toggles = screen.getAllByRole("checkbox");
await userEvent.click(toggles[0]);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Update failed", "error");
});
});
it("displays specific error message from API", async () => {
mockFetchPiExtensions.mockResolvedValueOnce(mockExtensions);
mockUpdatePiExtensions.mockRejectedValueOnce(new Error("Extension file not found"));
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getAllByRole("checkbox")).toHaveLength(4);
});
const toggles = screen.getAllByRole("checkbox");
await userEvent.click(toggles[0]);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Extension file not found", "error");
});
});
});
});