Files
fusion/packages/dashboard/app/components/__tests__/PiExtensionsManager.test.tsx
Fusion 3d272b0d29 feat(FN-1944): add Pi settings package management flow
- Add dashboard backend routes for Pi settings management with comprehensive route tests
- Add API client functions and tests for fetching and updating Pi settings endpoints
- Replace PiExtensionsManager with package management UI and add supporting dashboard styles
- Rewrite and fix PiExtensionsManager tests and include a changeset for @gsxdsm/fusion
2026-04-18 06:31:41 -07:00

387 lines
14 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";
// Mock API module
const mockFetchPiSettings = vi.fn();
const mockUpdatePiSettings = vi.fn();
const mockInstallPiPackage = vi.fn();
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Package: ({ size }: { size?: number }) => (
<span data-testid="icon-package">Package-{size || 16}</span>
),
Puzzle: ({ size }: { size?: number }) => (
<span data-testid="icon-puzzle">Puzzle-{size || 14}</span>
),
BookOpen: ({ size }: { size?: number }) => (
<span data-testid="icon-book">BookOpen-{size || 14}</span>
),
FileText: ({ size }: { size?: number }) => (
<span data-testid="icon-file">FileText-{size || 14}</span>
),
Palette: ({ size }: { size?: number }) => (
<span data-testid="icon-palette">Palette-{size || 14}</span>
),
Plus: ({ size }: { size?: number }) => (
<span data-testid="icon-plus">Plus-{size || 14}</span>
),
X: ({ size }: { size?: number }) => (
<span data-testid="icon-x">X-{size || 12}</span>
),
ChevronDown: ({ size }: { size?: number }) => (
<span data-testid="icon-chevron-down">ChevronDown-{size || 14}</span>
),
ChevronRight: ({ size }: { size?: number }) => (
<span data-testid="icon-chevron-right">ChevronRight-{size || 14}</span>
),
RefreshCw: ({ size, className }: { size?: number; className?: string }) => (
<span data-testid="icon-refresh" className={className}>
RefreshCw-{size || 16}
</span>
),
Trash2: ({ size }: { size?: number }) => (
<span data-testid="icon-trash">Trash2-{size || 14}</span>
),
}));
vi.mock("../../api", () => ({
fetchPiSettings: (...args: unknown[]) => mockFetchPiSettings(...args),
updatePiSettings: (...args: unknown[]) => mockUpdatePiSettings(...args),
installPiPackage: (...args: unknown[]) => mockInstallPiPackage(...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 addToast = vi.fn();
describe("PiExtensionsManager", () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
describe("Rendering", () => {
it("renders Pi Extensions header", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Pi Extensions" })).toBeTruthy();
});
});
it("renders add package form", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByRole("textbox")).toBeTruthy();
expect(screen.getByRole("button", { name: /Add/i })).toBeTruthy();
});
});
it("renders package list with source badges", async () => {
mockFetchPiSettings.mockResolvedValueOnce(mockPiSettings);
render(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
expect(screen.getByText("Loading Pi settings…")).toBeTruthy();
});
it("refresh button shows spinning class while loading", async () => {
mockFetchPiSettings.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 packages configured", async () => {
mockFetchPiSettings.mockResolvedValueOnce({ packages: [], extensions: [], skills: [], prompts: [], themes: [] });
render(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByTestId("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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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("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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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(<PiExtensionsManager addToast={addToast} />);
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");
});
});
});
});