feat(FN-1413): merge fusion/fn-1413
This commit is contained in:
@@ -7,14 +7,32 @@
|
||||
* - Enable/disable plugins
|
||||
* - Configure plugin settings
|
||||
* - Uninstall plugins
|
||||
* - Live updates via SSE (plugin:lifecycle events)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin } from "../api";
|
||||
import type { PluginInstallation } from "@fusion/core";
|
||||
import type { PluginInstallation, PluginState } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
/** SSE heartbeat watchdog timeout (matches useTasks hook) */
|
||||
const SSE_HEARTBEAT_TIMEOUT_MS = 45_000;
|
||||
|
||||
/** Normalized plugin lifecycle payload from SSE plugin:lifecycle events */
|
||||
interface PluginLifecyclePayload {
|
||||
pluginId: string;
|
||||
transition: "installing" | "enabled" | "disabled" | "error" | "uninstalled" | "settings-updated";
|
||||
sourceEvent: string;
|
||||
timestamp: string;
|
||||
projectId?: string;
|
||||
enabled: boolean;
|
||||
state: PluginState;
|
||||
version: string;
|
||||
settings: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PluginManagerProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
projectId?: string;
|
||||
@@ -55,6 +73,128 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
loadPlugins();
|
||||
}, [loadPlugins]);
|
||||
|
||||
// SSE live updates for plugin lifecycle events
|
||||
const pluginsRef = useRef<PluginInstallation[]>([]);
|
||||
pluginsRef.current = plugins;
|
||||
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
/** Reset the SSE heartbeat watchdog */
|
||||
const resetHeartbeat = () => {
|
||||
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = setTimeout(() => {
|
||||
if (!closedByCleanup) {
|
||||
// Connection appears dead — force reconnect
|
||||
es.close();
|
||||
if (!closedByCleanup) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (!closedByCleanup) {
|
||||
void loadPlugins(); // Fallback: refetch all plugins
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}, SSE_HEARTBEAT_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
// Start the heartbeat watchdog immediately
|
||||
resetHeartbeat();
|
||||
|
||||
const handlePluginLifecycle = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
try {
|
||||
const payload: PluginLifecyclePayload = JSON.parse(e.data);
|
||||
|
||||
// Filter by projectId if in project-scoped mode
|
||||
if (projectId && payload.projectId && payload.projectId !== projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPlugins = pluginsRef.current;
|
||||
|
||||
switch (payload.transition) {
|
||||
case "installing":
|
||||
case "enabled":
|
||||
case "disabled":
|
||||
case "settings-updated":
|
||||
// Update existing plugin or add if new
|
||||
setPlugins((prev) => {
|
||||
const existingIndex = prev.findIndex((p) => p.id === payload.pluginId);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing plugin
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = {
|
||||
...updated[existingIndex],
|
||||
enabled: payload.enabled,
|
||||
state: payload.state,
|
||||
settings: payload.settings,
|
||||
error: payload.error,
|
||||
};
|
||||
return updated;
|
||||
} else {
|
||||
// New plugin added via another session — refetch to get full data
|
||||
void loadPlugins();
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case "uninstalled":
|
||||
// Remove plugin from list
|
||||
setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId));
|
||||
break;
|
||||
|
||||
case "error":
|
||||
// Update plugin state to error
|
||||
setPlugins((prev) => {
|
||||
const existingIndex = prev.findIndex((p) => p.id === payload.pluginId);
|
||||
if (existingIndex >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = {
|
||||
...updated[existingIndex],
|
||||
state: payload.state,
|
||||
error: payload.error,
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
es.addEventListener("plugin:lifecycle", handlePluginLifecycle);
|
||||
|
||||
// Also listen for the heartbeat to keep the connection alive
|
||||
es.addEventListener("heartbeat", () => {
|
||||
resetHeartbeat();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
if (closedByCleanup) return;
|
||||
// EventSource will automatically attempt reconnection
|
||||
// We just need to clear our heartbeat watchdog
|
||||
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
||||
};
|
||||
|
||||
return () => {
|
||||
closedByCleanup = true;
|
||||
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
es.removeEventListener("plugin:lifecycle", handlePluginLifecycle);
|
||||
es.close();
|
||||
};
|
||||
}, [projectId, loadPlugins]);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!installPath.trim()) {
|
||||
addToast("Please enter a plugin path", "error");
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,9 +48,28 @@ vi.mock("../../api", () => ({
|
||||
favoriteModels: [],
|
||||
})),
|
||||
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
||||
// Plugin API mocks
|
||||
fetchPlugins: vi.fn(() => Promise.resolve([])),
|
||||
installPlugin: vi.fn(() => Promise.resolve({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", state: "started" as const, enabled: true, settings: {}, settingsSchema: {} })),
|
||||
enablePlugin: vi.fn(() => Promise.resolve({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", state: "started" as const, enabled: true, settings: {}, settingsSchema: {} })),
|
||||
disablePlugin: vi.fn(() => Promise.resolve({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", state: "stopped" as const, enabled: false, settings: {}, settingsSchema: {} })),
|
||||
uninstallPlugin: vi.fn(() => Promise.resolve()),
|
||||
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
|
||||
updatePluginSettings: vi.fn(() => Promise.resolve({})),
|
||||
reloadPlugin: vi.fn(() => Promise.resolve({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", state: "started" as const, enabled: true, settings: {}, settingsSchema: {} })),
|
||||
}));
|
||||
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification } from "../../api";
|
||||
// Mock PluginManager to avoid SSE setup in tests
|
||||
vi.mock("../PluginManager", () => ({
|
||||
PluginManager: vi.fn(({ addToast }) => (
|
||||
<div data-testid="plugin-manager">
|
||||
<p>Plugin Manager Component</p>
|
||||
<button onClick={() => addToast?.("Plugin action", "success")}>Mock Plugin Action</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchPlugins } from "../../api";
|
||||
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user