Files
fusion/packages/dashboard/app/components/__tests__/PluginManager.test.tsx
2026-04-10 23:20:39 -07:00

645 lines
20 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, act, cleanup, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { PluginInstallation } from "@fusion/core";
// Define mock data outside vi.mock
const mockPlugins: PluginInstallation[] = [
{
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "started",
enabled: true,
description: "A test plugin",
author: "Test Author",
homepage: "https://example.com",
settings: { apiKey: "test-key" },
settingsSchema: {
apiKey: { type: "string", label: "API Key", description: "The API key", required: true },
},
},
{
id: "plugin-b",
name: "Test Plugin B",
version: "2.0.0",
state: "stopped",
enabled: false,
description: "Another test plugin",
settings: {},
settingsSchema: {},
},
];
// Mock API module - must be defined inline in vi.mock
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() => Promise.resolve({
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "started" as const,
enabled: true,
settings: {},
settingsSchema: {},
})),
enablePlugin: vi.fn(() => Promise.resolve({
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "started" as const,
enabled: true,
settings: {},
settingsSchema: {},
})),
disablePlugin: vi.fn(() => Promise.resolve({
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "stopped" as const,
enabled: false,
settings: {},
settingsSchema: {},
})),
uninstallPlugin: vi.fn(() => Promise.resolve()),
fetchPluginSettings: vi.fn(() => Promise.resolve({ apiKey: "test-key" })),
updatePluginSettings: vi.fn(() => Promise.resolve({ apiKey: "updated-key" })),
reloadPlugin: vi.fn(() => Promise.resolve({
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "started" as const,
enabled: true,
settings: {},
settingsSchema: {},
})),
}));
// Import after vi.mock so the mock is in place
import { PluginManager } from "../PluginManager";
import {
fetchPlugins,
installPlugin,
enablePlugin,
disablePlugin,
uninstallPlugin,
fetchPluginSettings,
updatePluginSettings,
} from "../../api";
const addToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default implementations
vi.mocked(fetchPlugins).mockResolvedValue([]);
vi.mocked(installPlugin).mockResolvedValue({
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "started" as const,
enabled: true,
settings: {},
settingsSchema: {},
});
vi.mocked(enablePlugin).mockResolvedValue({
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "started" as const,
enabled: true,
settings: {},
settingsSchema: {},
});
vi.mocked(disablePlugin).mockResolvedValue({
id: "plugin-a",
name: "Test Plugin A",
version: "1.0.0",
state: "stopped" as const,
enabled: false,
settings: {},
settingsSchema: {},
});
vi.mocked(uninstallPlugin).mockResolvedValue();
vi.mocked(fetchPluginSettings).mockResolvedValue({ apiKey: "test-key" });
vi.mocked(updatePluginSettings).mockResolvedValue({ apiKey: "updated-key" });
// EventSource mock setup
const eventSourceInstance = {
url: "",
readyState: 1,
close: vi.fn(),
addEventListener: vi.fn((event: string, handler: (e: MessageEvent) => void) => {
(eventSourceInstance as any).handlers = (eventSourceInstance as any).handlers || {};
(eventSourceInstance as any).handlers[event] = handler;
}),
removeEventListener: vi.fn(),
onerror: null,
onopen: null,
onmessage: null,
};
const MockEventSource = vi.fn(() => eventSourceInstance) as unknown as typeof EventSource;
MockEventSource.CONNECTING = 0;
MockEventSource.OPEN = 1;
MockEventSource.CLOSED = 2;
vi.stubGlobal("EventSource", MockEventSource);
// Store reference for tests that need to trigger events
(globalThis as any).__testEventSourceInstance = eventSourceInstance;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
delete (globalThis as any).__testEventSourceInstance;
});
describe("PluginManager", () => {
it("renders loading state initially", async () => {
vi.mocked(fetchPlugins).mockImplementationOnce(
() => new Promise(() => {}) // Never resolves to keep loading state
);
render(<PluginManager addToast={addToast} />);
expect(screen.getByText("Loading plugins...")).toBeTruthy();
});
it("renders empty state when no plugins are installed", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
expect(screen.getByText("No plugins installed.")).toBeTruthy();
expect(screen.getByRole("button", { name: /Install$/ })).toBeTruthy();
});
it("renders plugin list when plugins are available", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
expect(screen.getByText("Test Plugin B")).toBeTruthy();
expect(screen.getByText("v1.0.0")).toBeTruthy();
expect(screen.getByText("v2.0.0")).toBeTruthy();
});
it("shows install form when Install button is clicked", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
const installButton = screen.getByRole("button", { name: /Install$/ });
await userEvent.click(installButton);
expect(screen.getByPlaceholderText("Local path to plugin directory")).toBeTruthy();
expect(screen.getByRole("button", { name: /Cancel/i })).toBeTruthy();
});
it("calls installPlugin with the provided path", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
// Click the header Install button
const headerInstallButton = screen.getByRole("button", { name: /Install$/ });
await userEvent.click(headerInstallButton);
const input = screen.getByPlaceholderText("Local path to plugin directory");
await userEvent.type(input, "/path/to/plugin");
// Get the form container and find the Install button within it
const formContainer = screen.getByPlaceholderText("Local path to plugin directory").closest(".plugin-install-form");
expect(formContainer).toBeTruthy();
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install$/ });
await userEvent.click(formInstallButton);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "/path/to/plugin" }, undefined);
});
});
it("shows error toast when install fails", async () => {
vi.mocked(installPlugin).mockRejectedValueOnce(new Error("Install failed"));
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
const headerInstallButton = screen.getByRole("button", { name: /Install$/ });
await userEvent.click(headerInstallButton);
const input = screen.getByPlaceholderText("Local path to plugin directory");
await userEvent.type(input, "/path/to/plugin");
const formContainer = screen.getByPlaceholderText("Local path to plugin directory").closest(".plugin-install-form");
expect(formContainer).toBeTruthy();
const formInstallButton = within(formContainer as HTMLElement).getByRole("button", { name: /Install$/ });
await userEvent.click(formInstallButton);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Install failed"),
"error"
);
});
});
it("enables plugin when toggle is clicked", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([{ ...mockPlugins[0], enabled: false }]);
vi.mocked(enablePlugin).mockResolvedValueOnce({ ...mockPlugins[0], enabled: true });
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const toggle = screen.getByRole("checkbox");
expect(toggle).toBeTruthy();
expect(toggle).not.toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(enablePlugin).toHaveBeenCalledWith("plugin-a", undefined);
});
});
it("disables plugin when toggle is clicked", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([{ ...mockPlugins[0], enabled: true }]);
vi.mocked(disablePlugin).mockResolvedValueOnce({ ...mockPlugins[0], enabled: false });
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const toggle = screen.getByRole("checkbox");
expect(toggle).toBeTruthy();
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(disablePlugin).toHaveBeenCalledWith("plugin-a", undefined);
});
});
it("asks for confirmation before uninstalling", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const uninstallButtons = screen.getAllByTitle("Uninstall");
await userEvent.click(uninstallButtons[0]);
expect(confirmSpy).toHaveBeenCalled();
expect(uninstallPlugin).not.toHaveBeenCalled();
});
it("calls uninstallPlugin when confirmed", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
vi.mocked(uninstallPlugin).mockResolvedValueOnce(undefined);
vi.spyOn(window, "confirm").mockReturnValue(true);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const uninstallButtons = screen.getAllByTitle("Uninstall");
await userEvent.click(uninstallButtons[0]);
await waitFor(() => {
expect(uninstallPlugin).toHaveBeenCalledWith("plugin-a", undefined);
});
});
it("shows plugin detail view when settings button is clicked", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const settingsButtons = screen.getAllByTitle("Settings");
await userEvent.click(settingsButtons[0]);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeTruthy();
expect(screen.getByDisplayValue("test-key")).toBeTruthy();
});
});
it("saves plugin settings", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const settingsButtons = screen.getAllByTitle("Settings");
await userEvent.click(settingsButtons[0]);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeTruthy();
});
const input = screen.getByDisplayValue("test-key") as HTMLInputElement;
await userEvent.clear(input);
await userEvent.type(input, "updated-key");
const saveButton = screen.getByRole("button", { name: /Save Settings/i });
await userEvent.click(saveButton);
await waitFor(() => {
expect(updatePluginSettings).toHaveBeenCalledWith(
"plugin-a",
expect.objectContaining({ apiKey: "updated-key" }),
undefined
);
expect(addToast).toHaveBeenCalledWith("Settings saved", "success");
});
});
it("refreshes plugin list when refresh button is clicked", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
vi.mocked(fetchPlugins).mockClear();
vi.mocked(fetchPlugins).mockResolvedValueOnce([mockPlugins[1]]);
const refreshButton = screen.getByTitle("Refresh");
await userEvent.click(refreshButton);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
});
it("uses projectId when provided", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([]);
render(<PluginManager addToast={addToast} projectId="proj-123" />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalledWith("proj-123");
});
});
describe("SSE Live Updates", () => {
it("subscribes to plugin:lifecycle SSE events", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
expect(EventSource).toHaveBeenCalled();
});
it("subscribes to project-scoped SSE when projectId is provided", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
render(<PluginManager addToast={addToast} projectId="proj-456" />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
expect(EventSource).toHaveBeenCalledWith("/api/events?projectId=proj-456");
});
it("handles plugin enabled SSE event", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([{ ...mockPlugins[0], enabled: false }]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const eventSourceInstance = (globalThis as any).__testEventSourceInstance;
const eventHandler = eventSourceInstance?.handlers?.["plugin:lifecycle"];
expect(eventHandler).toBeTruthy();
act(() => {
eventHandler({
data: JSON.stringify({
pluginId: "plugin-a",
transition: "enabled",
sourceEvent: "plugin:enabled",
timestamp: new Date().toISOString(),
enabled: true,
state: "started",
version: "1.0.0",
settings: {},
}),
});
});
await waitFor(() => {
const toggle = screen.getByRole("checkbox");
expect(toggle).toBeChecked();
});
});
it("handles plugin disabled SSE event", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([{ ...mockPlugins[0], enabled: true }]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const eventSourceInstance = (globalThis as any).__testEventSourceInstance;
const eventHandler = eventSourceInstance?.handlers?.["plugin:lifecycle"];
act(() => {
eventHandler({
data: JSON.stringify({
pluginId: "plugin-a",
transition: "disabled",
sourceEvent: "plugin:disabled",
timestamp: new Date().toISOString(),
enabled: false,
state: "stopped",
version: "1.0.0",
settings: {},
}),
});
});
await waitFor(() => {
const toggle = screen.getByRole("checkbox");
expect(toggle).not.toBeChecked();
});
});
it("handles plugin uninstalled SSE event", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const eventSourceInstance = (globalThis as any).__testEventSourceInstance;
const eventHandler = eventSourceInstance?.handlers?.["plugin:lifecycle"];
act(() => {
eventHandler({
data: JSON.stringify({
pluginId: "plugin-a",
transition: "uninstalled",
sourceEvent: "plugin:unregistered",
timestamp: new Date().toISOString(),
enabled: false,
state: "stopped",
version: "1.0.0",
settings: {},
}),
});
});
await waitFor(() => {
expect(screen.queryByText("Test Plugin A")).toBeNull();
});
});
it("handles plugin settings-updated SSE event", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce([{ ...mockPlugins[0], settings: { oldKey: "old" } }]);
vi.mocked(fetchPluginSettings).mockResolvedValueOnce({ newKey: "new" });
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const settingsButtons = screen.getAllByTitle("Settings");
await userEvent.click(settingsButtons[0]);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeTruthy();
});
const eventSourceInstance = (globalThis as any).__testEventSourceInstance;
const eventHandler = eventSourceInstance?.handlers?.["plugin:lifecycle"];
act(() => {
eventHandler({
data: JSON.stringify({
pluginId: "plugin-a",
transition: "settings-updated",
sourceEvent: "plugin:updated",
timestamp: new Date().toISOString(),
enabled: true,
state: "started",
version: "1.0.0",
settings: { newKey: "new" },
}),
});
});
await waitFor(() => {
expect(fetchPluginSettings).toHaveBeenCalledWith("plugin-a", undefined);
});
});
it("filters events by projectId in project-scoped mode", async () => {
// Start with plugin disabled
vi.mocked(fetchPlugins).mockResolvedValueOnce([{ ...mockPlugins[0], enabled: false }]);
render(<PluginManager addToast={addToast} projectId="proj-789" />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
// Verify initial state - toggle should NOT be checked
const toggle = screen.getByRole("checkbox");
expect(toggle).not.toBeChecked();
// Now send an SSE event from a DIFFERENT project trying to enable the plugin
const eventSourceInstance = (globalThis as any).__testEventSourceInstance;
const eventHandler = eventSourceInstance?.handlers?.["plugin:lifecycle"];
act(() => {
eventHandler({
data: JSON.stringify({
pluginId: "plugin-a",
transition: "enabled",
sourceEvent: "plugin:enabled",
timestamp: new Date().toISOString(),
projectId: "other-project", // Different project - this event should be filtered
enabled: true,
state: "started",
version: "1.0.0",
settings: {},
}),
});
});
// Toggle should STILL NOT be checked since event is from different project (filtered)
await waitFor(() => {
const filteredToggle = screen.getByRole("checkbox");
expect(filteredToggle).not.toBeChecked();
});
});
it("cleans up EventSource on unmount", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
const eventSourceInstance = (globalThis as any).__testEventSourceInstance;
const { unmount } = render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(fetchPlugins).toHaveBeenCalled();
});
expect(EventSource).toHaveBeenCalled();
unmount();
expect(eventSourceInstance?.close).toHaveBeenCalled();
});
});
});