/** * PluginRunner Unit Tests * * Tests the PluginRunner class which orchestrates plugin loading into the engine, * invokes hooks at lifecycle points, and provides plugin tools to agent sessions. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { PluginRunner, type PluginRunnerOptions } from "./plugin-runner.js"; import type { PluginLoader, PluginStore, PluginInstallation } from "@fusion/core"; import type { FusionPlugin, PluginToolDefinition } from "@fusion/core"; // Mock the logger to suppress output during tests vi.mock("./logger.js", () => ({ createLogger: () => ({ log: vi.fn(), warn: vi.fn(), error: vi.fn(), }), executorLog: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); describe("PluginRunner", () => { let mockPluginLoader: { loadAllPlugins: ReturnType; stopAllPlugins: ReturnType; invokeHook: ReturnType; getPluginTools: ReturnType; getPluginRoutes: ReturnType; getPluginUiSlots: ReturnType; getLoadedPlugins: ReturnType; getPlugin: ReturnType; loadPlugin: ReturnType; stopPlugin: ReturnType; reloadPlugin: ReturnType; on: ReturnType; off: ReturnType; }; let mockPluginStore: { on: ReturnType; off: ReturnType; getPlugin: ReturnType; }; let mockTaskStore: { on: ReturnType; off: ReturnType; getTask: ReturnType; }; let pluginRunner: PluginRunner; const createMockPlugin = (overrides: Partial = {}): FusionPlugin => ({ manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0", }, state: "started", hooks: {}, ...overrides, }); beforeEach(() => { // Create fresh mocks for each test mockPluginLoader = { loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 2, errors: 0 }), stopAllPlugins: vi.fn().mockResolvedValue(undefined), invokeHook: vi.fn().mockResolvedValue(undefined), getPluginTools: vi.fn().mockReturnValue([]), getPluginRoutes: vi.fn().mockReturnValue([]), getPluginUiSlots: vi.fn().mockReturnValue([]), getLoadedPlugins: vi.fn().mockReturnValue([]), getPlugin: vi.fn(), loadPlugin: vi.fn().mockResolvedValue({}), stopPlugin: vi.fn().mockResolvedValue(undefined), reloadPlugin: vi.fn().mockResolvedValue(undefined), on: vi.fn(), off: vi.fn(), }; const mockOn = vi.fn(); const mockOff = vi.fn(); mockTaskStore = { on: mockOn, off: mockOff, getTask: vi.fn(), }; mockPluginStore = { on: mockOn, off: mockOff, getPlugin: vi.fn().mockResolvedValue({ id: "test-plugin", name: "Test Plugin", version: "1.0.0", settings: {}, settingsSchema: undefined, }), }; pluginRunner = new PluginRunner({ pluginLoader: mockPluginLoader as unknown as PluginLoader, pluginStore: mockPluginStore as unknown as PluginStore, taskStore: mockTaskStore as unknown as import("@fusion/core").TaskStore, rootDir: "/test/root", }); }); afterEach(() => { vi.clearAllMocks(); }); describe("init()", () => { it("should load all plugins", async () => { await pluginRunner.init(); expect(mockPluginLoader.loadAllPlugins).toHaveBeenCalled(); }); it("should subscribe to plugin store events", async () => { await pluginRunner.init(); // Should subscribe to plugin lifecycle events expect(mockPluginStore.on).toHaveBeenCalledWith( "plugin:enabled", expect.any(Function) ); expect(mockPluginStore.on).toHaveBeenCalledWith( "plugin:disabled", expect.any(Function) ); expect(mockPluginStore.on).toHaveBeenCalledWith( "plugin:unregistered", expect.any(Function) ); }); it("should subscribe to plugin loader events for cache invalidation", async () => { await pluginRunner.init(); expect(mockPluginLoader.on).toHaveBeenCalledWith( "plugin:loaded", expect.any(Function) ); expect(mockPluginLoader.on).toHaveBeenCalledWith( "plugin:unloaded", expect.any(Function) ); expect(mockPluginLoader.on).toHaveBeenCalledWith( "plugin:reloaded", expect.any(Function) ); }); }); describe("shutdown()", () => { it("should stop all plugins", async () => { await pluginRunner.init(); await pluginRunner.shutdown(); expect(mockPluginLoader.stopAllPlugins).toHaveBeenCalled(); }); it("should unsubscribe from plugin store events", async () => { await pluginRunner.init(); await pluginRunner.shutdown(); expect(mockPluginStore.off).toHaveBeenCalledWith( "plugin:enabled", expect.any(Function) ); expect(mockPluginStore.off).toHaveBeenCalledWith( "plugin:disabled", expect.any(Function) ); }); it("should unsubscribe from task store events", async () => { await pluginRunner.init(); await pluginRunner.shutdown(); expect(mockTaskStore.off).toHaveBeenCalledWith( "task:created", expect.any(Function) ); expect(mockTaskStore.off).toHaveBeenCalledWith( "task:moved", expect.any(Function) ); }); }); describe("invokeHook()", () => { it("should delegate to pluginLoader.invokeHook", async () => { await pluginRunner.init(); await pluginRunner.invokeHook("onLoad"); expect(mockPluginLoader.invokeHook).toHaveBeenCalledWith("onLoad"); }); it("should pass multiple arguments to the hook", async () => { await pluginRunner.init(); await pluginRunner.invokeHook("onTaskMoved", "FN-001", "todo", "in-progress"); expect(mockPluginLoader.invokeHook).toHaveBeenCalledWith( "onTaskMoved", "FN-001", "todo", "in-progress" ); }); it("should propagate hook invocation errors", async () => { mockPluginLoader.invokeHook = vi.fn().mockRejectedValue(new Error("Hook failed")); await pluginRunner.init(); // Errors are propagated to caller await expect( pluginRunner.invokeHook("onLoad") ).rejects.toThrow("Hook failed"); }); }); describe("getPluginTools()", () => { it("should return empty array when no plugins have tools", async () => { mockPluginLoader.getPluginTools.mockReturnValue([]); await pluginRunner.init(); const tools = pluginRunner.getPluginTools(); expect(tools).toEqual([]); }); it("should cache tools and invalidate on plugin events", async () => { const mockTools: PluginToolDefinition[] = [ { name: "test-tool", description: "A test tool", parameters: { type: "object", properties: {} }, execute: vi.fn(), }, ]; mockPluginLoader.getPluginTools.mockReturnValue(mockTools); await pluginRunner.init(); const tools1 = pluginRunner.getPluginTools(); // Same call should return cached result const tools2 = pluginRunner.getPluginTools(); expect(tools1).toBe(tools2); // Simulate plugin event that invalidates cache const reloadHandler = mockPluginLoader.on.mock.calls.find( call => call[0] === "plugin:reloaded" )?.[1]; if (reloadHandler) { reloadHandler({ pluginId: "test-plugin" }); } // Next call should rebuild cache const tools3 = pluginRunner.getPluginTools(); expect(mockPluginLoader.getPluginTools).toHaveBeenCalledTimes(2); }); }); describe("getPluginRoutes()", () => { it("should return routes from the loader", async () => { const mockRoutes = [ { pluginId: "test-plugin", route: { method: "GET", path: "/api/test", handler: vi.fn(), }, }, ]; mockPluginLoader.getPluginRoutes.mockReturnValue(mockRoutes); await pluginRunner.init(); const routes = pluginRunner.getPluginRoutes(); expect(routes).toEqual(mockRoutes); }); it("should return empty array when no routes", async () => { mockPluginLoader.getPluginRoutes.mockReturnValue([]); await pluginRunner.init(); const routes = pluginRunner.getPluginRoutes(); expect(routes).toEqual([]); }); }); describe("getPluginUiSlots()", () => { it("should return empty array when no plugins have uiSlots", async () => { mockPluginLoader.getPluginUiSlots.mockReturnValue([]); await pluginRunner.init(); const slots = pluginRunner.getPluginUiSlots(); expect(slots).toEqual([]); }); it("should return cached slots after plugins load", async () => { const mockSlots = [ { pluginId: "test-plugin", slot: { slotId: "task-detail-tab", label: "Task Details", componentPath: "./components/TaskDetailTab.js", }, }, ]; mockPluginLoader.getPluginUiSlots.mockReturnValue(mockSlots); await pluginRunner.init(); const slots1 = pluginRunner.getPluginUiSlots(); const slots2 = pluginRunner.getPluginUiSlots(); expect(slots1).toEqual(mockSlots); expect(slots2).toBe(slots1); // Same reference (cached) }); it("should invalidate cache on plugin:reloaded event", async () => { const mockSlots = [ { pluginId: "test-plugin", slot: { slotId: "custom-tab", label: "Custom Tab", componentPath: "./components/CustomTab.js", }, }, ]; mockPluginLoader.getPluginUiSlots.mockReturnValue(mockSlots); await pluginRunner.init(); const slots1 = pluginRunner.getPluginUiSlots(); expect(slots1).toEqual(mockSlots); // Simulate plugin:reloaded event that invalidates cache const reloadHandler = mockPluginLoader.on.mock.calls.find( call => call[0] === "plugin:reloaded" )?.[1]; if (reloadHandler) { reloadHandler({ pluginId: "test-plugin" }); } // Next call should rebuild cache const newSlots = [ { pluginId: "test-plugin", slot: { slotId: "updated-tab", label: "Updated Tab", componentPath: "./components/UpdatedTab.js", }, }, ]; mockPluginLoader.getPluginUiSlots.mockReturnValue(newSlots); const slots2 = pluginRunner.getPluginUiSlots(); expect(slots2).toEqual(newSlots); expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:enabled event", async () => { mockPluginLoader.getPluginUiSlots.mockReturnValue([]); await pluginRunner.init(); // Get initial slots pluginRunner.getPluginUiSlots(); // Simulate plugin:enabled event const enabledHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:enabled" )?.[1]; const newPlugin = { id: "new-plugin", name: "New Plugin", version: "1.0.0", path: "/test/path", enabled: true, state: "stopped" as const, settings: {}, createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z", }; if (enabledHandler) { enabledHandler(newPlugin); } // Next call should rebuild cache pluginRunner.getPluginUiSlots(); expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:disabled event", async () => { mockPluginLoader.getPluginUiSlots.mockReturnValue([]); await pluginRunner.init(); // Get initial slots pluginRunner.getPluginUiSlots(); // Simulate plugin:disabled event const disabledHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:disabled" )?.[1]; const plugin = { id: "test-plugin", name: "Test Plugin", version: "1.0.0", path: "/test/path", enabled: true, state: "stopped" as const, settings: {}, createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z", }; if (disabledHandler) { disabledHandler(plugin); } // Next call should rebuild cache pluginRunner.getPluginUiSlots(); expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:stateChanged event", async () => { mockPluginLoader.getPluginUiSlots.mockReturnValue([]); await pluginRunner.init(); // Get initial slots pluginRunner.getPluginUiSlots(); // Simulate plugin:stateChanged event const stateHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:stateChanged" )?.[1]; if (stateHandler) { stateHandler(); } // Next call should rebuild cache pluginRunner.getPluginUiSlots(); expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:updated event", async () => { mockPluginLoader.getPluginUiSlots.mockReturnValue([]); await pluginRunner.init(); // Get initial slots pluginRunner.getPluginUiSlots(); // Simulate plugin:updated event const updatedHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:updated" )?.[1]; if (updatedHandler) { updatedHandler(); } // Next call should rebuild cache pluginRunner.getPluginUiSlots(); expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); }); it("should invalidate cache on reloadPlugin()", async () => { mockPluginLoader.getPluginUiSlots.mockReturnValue([]); await pluginRunner.init(); // Get initial slots pluginRunner.getPluginUiSlots(); // Call reloadPlugin await pluginRunner.reloadPlugin("test-plugin"); // Next call should rebuild cache pluginRunner.getPluginUiSlots(); expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2); }); }); describe("getLoader() / getStore()", () => { it("should return the plugin loader", () => { const loader = pluginRunner.getLoader(); expect(loader).toBe(mockPluginLoader); }); it("should return the plugin store", () => { const store = pluginRunner.getStore(); expect(store).toBe(mockPluginStore); }); }); describe("reloadPlugin()", () => { it("should reload a plugin", async () => { await pluginRunner.init(); await pluginRunner.reloadPlugin("test-plugin"); expect(mockPluginLoader.reloadPlugin).toHaveBeenCalledWith("test-plugin"); }); }); describe("task lifecycle hooks", () => { it("should invoke onTaskCreated when task:created event fires", async () => { mockPluginLoader.invokeHook = vi.fn(); await pluginRunner.init(); // Find the task:created handler const createdHandler = mockTaskStore.on.mock.calls.find( call => call[0] === "task:created" )?.[1]; // Simulate task creation const mockTask = { id: "FN-001", title: "Test Task" }; if (createdHandler) { createdHandler(mockTask); } // Give async handler time to execute await new Promise(resolve => setTimeout(resolve, 10)); expect(mockPluginLoader.invokeHook).toHaveBeenCalledWith( "onTaskCreated", mockTask ); }); it("should invoke onTaskMoved when task:moved event fires", async () => { mockPluginLoader.invokeHook = vi.fn(); await pluginRunner.init(); // Find the task:moved handler const movedHandler = mockTaskStore.on.mock.calls.find( call => call[0] === "task:moved" )?.[1]; // Simulate task move const mockTask = { id: "FN-001", title: "Test Task" }; if (movedHandler) { movedHandler({ task: mockTask, from: "todo", to: "in-progress" }); } // Give async handler time to execute await new Promise(resolve => setTimeout(resolve, 10)); expect(mockPluginLoader.invokeHook).toHaveBeenCalledWith( "onTaskMoved", mockTask, "todo", "in-progress" ); }); it("should invoke onTaskCompleted when task moves to done", async () => { mockPluginLoader.invokeHook = vi.fn(); await pluginRunner.init(); // Find the task:moved handler const movedHandler = mockTaskStore.on.mock.calls.find( call => call[0] === "task:moved" )?.[1]; // Simulate task moved to done const mockTask = { id: "FN-001", title: "Test Task" }; if (movedHandler) { movedHandler({ task: mockTask, from: "in-progress", to: "done" }); } // Give async handler time to execute await new Promise(resolve => setTimeout(resolve, 50)); expect(mockPluginLoader.invokeHook).toHaveBeenCalledWith( "onTaskCompleted", mockTask ); }); it("should NOT invoke onTaskCompleted when task moves elsewhere", async () => { mockPluginLoader.invokeHook = vi.fn(); await pluginRunner.init(); // Find the task:moved handler const movedHandler = mockTaskStore.on.mock.calls.find( call => call[0] === "task:moved" )?.[1]; // Simulate task moved to in-progress const mockTask = { id: "FN-001", title: "Test Task" }; if (movedHandler) { movedHandler({ task: mockTask, from: "todo", to: "in-progress" }); } // Give async handler time to execute await new Promise(resolve => setTimeout(resolve, 50)); expect(mockPluginLoader.invokeHook).not.toHaveBeenCalledWith( "onTaskCompleted", expect.anything() ); }); }); describe("plugin hot-reload integration", () => { it("should handle plugin:enabled event", async () => { await pluginRunner.init(); // Find the handler const enabledHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:enabled" )?.[1]; const mockPlugin = { id: "new-plugin", name: "New Plugin", version: "1.0.0", path: "/test/path", enabled: true, state: "stopped" as const, settings: {}, createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z", }; // Should not throw if (enabledHandler) { enabledHandler(mockPlugin); } expect(true).toBe(true); // Handler exists and doesn't throw }); it("should handle plugin:disabled event", async () => { await pluginRunner.init(); // Find the handler const disabledHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:disabled" )?.[1]; const mockPlugin = { id: "new-plugin", name: "New Plugin", version: "1.0.0", path: "/test/path", enabled: true, state: "stopped" as const, settings: {}, createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z", }; // Should not throw if (disabledHandler) { disabledHandler(mockPlugin); } expect(true).toBe(true); // Handler exists and doesn't throw }); it("should handle plugin:stateChanged event", async () => { await pluginRunner.init(); // Find the handler const stateHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:stateChanged" )?.[1]; // Should not throw if (stateHandler) { stateHandler(); } expect(true).toBe(true); // Handler exists and doesn't throw }); it("should handle plugin:updated event", async () => { await pluginRunner.init(); // Find the handler const updatedHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:updated" )?.[1]; // Should not throw if (updatedHandler) { updatedHandler(); } expect(true).toBe(true); // Handler exists and doesn't throw }); }); });