/** * 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"; import { createLogger } from "./logger.js"; // Mock the logger to suppress output during tests vi.mock("./logger.js", () => ({ createLogger: vi.fn(() => ({ 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; getPluginRuntimes: 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, }); const getPluginRunnerLogger = () => { const logger = vi.mocked(createLogger).mock.results.at(-1)?.value as { log: ReturnType; warn: ReturnType; error: ReturnType; } | undefined; if (!logger) { throw new Error("Expected plugin-runner logger to be initialized"); } return logger; }; 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([]), getPluginRuntimes: 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("getPluginRuntimes()", () => { it("should return empty array when no plugins have runtimes", async () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); await pluginRunner.init(); const runtimes = pluginRunner.getPluginRuntimes(); expect(runtimes).toEqual([]); }); it("should return cached runtimes after plugins load", async () => { const mockRuntimes = [ { pluginId: "test-plugin", runtime: { metadata: { runtimeId: "code-interpreter", name: "Code Interpreter", description: "Executes code", }, factory: async () => ({}), }, }, ]; mockPluginLoader.getPluginRuntimes.mockReturnValue(mockRuntimes); await pluginRunner.init(); const runtimes1 = pluginRunner.getPluginRuntimes(); const runtimes2 = pluginRunner.getPluginRuntimes(); expect(runtimes1).toEqual(mockRuntimes); expect(runtimes2).toBe(runtimes1); // Same reference (cached) }); it("should invalidate cache on plugin:reloaded event", async () => { const mockRuntimes = [ { pluginId: "test-plugin", runtime: { metadata: { runtimeId: "runtime-v1", name: "Runtime V1", }, factory: async () => ({}), }, }, ]; mockPluginLoader.getPluginRuntimes.mockReturnValue(mockRuntimes); await pluginRunner.init(); const runtimes1 = pluginRunner.getPluginRuntimes(); expect(runtimes1).toEqual(mockRuntimes); // 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 newRuntimes = [ { pluginId: "test-plugin", runtime: { metadata: { runtimeId: "runtime-v2", name: "Runtime V2", }, factory: async () => ({}), }, }, ]; mockPluginLoader.getPluginRuntimes.mockReturnValue(newRuntimes); const runtimes2 = pluginRunner.getPluginRuntimes(); expect(runtimes2).toEqual(newRuntimes); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:enabled event", async () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); await pluginRunner.init(); // Get initial runtimes pluginRunner.getPluginRuntimes(); // 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.getPluginRuntimes(); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:disabled event", async () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); await pluginRunner.init(); // Get initial runtimes pluginRunner.getPluginRuntimes(); // 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.getPluginRuntimes(); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:stateChanged event", async () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); await pluginRunner.init(); // Get initial runtimes pluginRunner.getPluginRuntimes(); // 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.getPluginRuntimes(); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:updated event", async () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); await pluginRunner.init(); // Get initial runtimes pluginRunner.getPluginRuntimes(); // 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.getPluginRuntimes(); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2); }); it("should invalidate cache on reloadPlugin()", async () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); await pluginRunner.init(); // Get initial runtimes pluginRunner.getPluginRuntimes(); // Call reloadPlugin await pluginRunner.reloadPlugin("test-plugin"); // Next call should rebuild cache pluginRunner.getPluginRuntimes(); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:loaded event", async () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); await pluginRunner.init(); // Get initial runtimes pluginRunner.getPluginRuntimes(); // Simulate plugin:loaded event const loadedHandler = mockPluginLoader.on.mock.calls.find( call => call[0] === "plugin:loaded" )?.[1]; if (loadedHandler) { loadedHandler({ pluginId: "test-plugin" }); } // Next call should rebuild cache pluginRunner.getPluginRuntimes(); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2); }); it("should invalidate cache on plugin:unloaded event", async () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); await pluginRunner.init(); // Get initial runtimes pluginRunner.getPluginRuntimes(); // Simulate plugin:unloaded event const unloadedHandler = mockPluginLoader.on.mock.calls.find( call => call[0] === "plugin:unloaded" )?.[1]; if (unloadedHandler) { unloadedHandler({ pluginId: "test-plugin" }); } // Next call should rebuild cache pluginRunner.getPluginRuntimes(); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2); }); }); describe("getRuntimeById()", () => { it("should return undefined when no runtimes exist", () => { mockPluginLoader.getPluginRuntimes.mockReturnValue([]); const result = pluginRunner.getRuntimeById("code-interpreter"); expect(result).toBeUndefined(); }); it("should return the runtime when runtimeId matches", () => { const mockRuntime = { metadata: { runtimeId: "code-interpreter", name: "Code Interpreter", }, factory: vi.fn(), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "code-plugin", runtime: mockRuntime as any }, ]); const result = pluginRunner.getRuntimeById("code-interpreter"); expect(result).toEqual({ pluginId: "code-plugin", runtime: mockRuntime }); }); it("should return undefined when runtimeId does not match", () => { const mockRuntime = { metadata: { runtimeId: "web-search", name: "Web Search", }, factory: vi.fn(), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "search-plugin", runtime: mockRuntime as any }, ]); const result = pluginRunner.getRuntimeById("code-interpreter"); expect(result).toBeUndefined(); }); it("should return first matching runtime when multiple plugins have same runtimeId", () => { const mockRuntime1 = { metadata: { runtimeId: "shared-id", name: "First Runtime", }, factory: vi.fn(), }; const mockRuntime2 = { metadata: { runtimeId: "shared-id", name: "Second Runtime", }, factory: vi.fn(), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "plugin-1", runtime: mockRuntime1 as any }, { pluginId: "plugin-2", runtime: mockRuntime2 as any }, ]); const result = pluginRunner.getRuntimeById("shared-id"); expect(result?.pluginId).toBe("plugin-1"); }); it("should find runtime even when cache is already built", () => { const mockRuntime = { metadata: { runtimeId: "cached-runtime", name: "Cached Runtime", }, factory: vi.fn(), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "cached-plugin", runtime: mockRuntime as any }, ]); // First call builds cache pluginRunner.getRuntimeById("other-id"); // Second call should find from cache const result = pluginRunner.getRuntimeById("cached-runtime"); expect(result?.pluginId).toBe("cached-plugin"); expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(1); }); }); describe("Paperclip runtime compatibility", () => { /** * Verify that the paperclip runtime registration from * plugins/fusion-plugin-paperclip-runtime is correctly resolvable * through the engine's runtime resolution system. */ it("should resolve paperclip runtime when registered", () => { const paperclipRuntime = { metadata: { runtimeId: "paperclip", name: "Paperclip Runtime", description: "Paperclip-backed AI session using the user's configured pi provider and model", version: "1.0.0", }, factory: vi.fn().mockResolvedValue({}), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any }, ]); const result = pluginRunner.getRuntimeById("paperclip"); expect(result).toBeDefined(); expect(result?.pluginId).toBe("fusion-plugin-paperclip-runtime"); expect(result?.runtime.metadata.runtimeId).toBe("paperclip"); expect(result?.runtime.metadata.name).toBe("Paperclip Runtime"); expect(result?.runtime.metadata.description).toContain("Paperclip"); expect(result?.runtime.metadata.version).toBe("1.0.0"); }); it("should expose paperclip runtime metadata correctly", () => { const paperclipRuntime = { metadata: { runtimeId: "paperclip", name: "Paperclip Runtime", description: "Paperclip-backed AI session using the user's configured pi provider and model", version: "1.0.0", }, factory: vi.fn().mockResolvedValue({}), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any }, ]); const runtimes = pluginRunner.getPluginRuntimes(); const paperclip = runtimes.find(r => r.runtime.metadata.runtimeId === "paperclip"); expect(paperclip).toBeDefined(); expect(paperclip?.runtime.metadata).toEqual({ runtimeId: "paperclip", name: "Paperclip Runtime", description: "Paperclip-backed AI session using the user's configured pi provider and model", version: "1.0.0", }); }); it("should allow factory invocation for paperclip runtime", async () => { const mockAdapter = { id: "paperclip", name: "Paperclip Runtime", createSession: vi.fn(), promptWithFallback: vi.fn(), describeModel: vi.fn(), dispose: vi.fn(), }; const paperclipRuntime = { metadata: { runtimeId: "paperclip", name: "Paperclip Runtime", description: "Paperclip-backed AI session using the user's configured pi provider and model", version: "1.0.0", }, factory: vi.fn().mockResolvedValue(mockAdapter), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any }, ]); const result = pluginRunner.getRuntimeById("paperclip"); expect(result).toBeDefined(); // Invoke the factory (simulating runtime instantiation) const context = { pluginId: "fusion-plugin-paperclip-runtime" }; const runtime = (await result!.runtime.factory(context as any)) as typeof mockAdapter; expect(paperclipRuntime.factory).toHaveBeenCalledWith(context); expect(runtime).toBe(mockAdapter); expect(runtime.id).toBe("paperclip"); expect(runtime.name).toBe("Paperclip Runtime"); }); }); describe("Hermes runtime compatibility", () => { it("should resolve hermes runtime when registered", () => { const hermesRuntime = { metadata: { runtimeId: "hermes", name: "Hermes Runtime", description: "Experimental Hermes runtime integration for Fusion tasks (implementation deferred to FN-2264)", version: "0.1.0", }, factory: vi.fn().mockReturnValue({}), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-hermes-runtime", runtime: hermesRuntime as any }, ]); const result = pluginRunner.getRuntimeById("hermes"); expect(result).toBeDefined(); expect(result?.pluginId).toBe("fusion-plugin-hermes-runtime"); expect(result?.runtime.metadata.runtimeId).toBe("hermes"); expect(result?.runtime.metadata.name).toBe("Hermes Runtime"); expect(result?.runtime.metadata.description).toContain("deferred to FN-2264"); expect(result?.runtime.metadata.version).toBe("0.1.0"); }); it("should expose hermes runtime metadata correctly", () => { const hermesRuntime = { metadata: { runtimeId: "hermes", name: "Hermes Runtime", description: "Experimental Hermes runtime integration for Fusion tasks (implementation deferred to FN-2264)", version: "0.1.0", }, factory: vi.fn().mockReturnValue({}), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-hermes-runtime", runtime: hermesRuntime as any }, ]); const runtimes = pluginRunner.getPluginRuntimes(); const hermes = runtimes.find(r => r.runtime.metadata.runtimeId === "hermes"); expect(hermes).toBeDefined(); expect(hermes?.runtime.metadata).toEqual({ runtimeId: "hermes", name: "Hermes Runtime", description: "Experimental Hermes runtime integration for Fusion tasks (implementation deferred to FN-2264)", version: "0.1.0", }); }); it("should allow factory invocation for hermes runtime", async () => { const hermesPlaceholderRuntime = { runtimeId: "hermes", version: "0.1.0", status: "deferred", message: "Hermes runtime implementation is deferred to FN-2264.", execute: vi.fn().mockRejectedValue(new Error("FN-2264")), }; const hermesRuntime = { metadata: { runtimeId: "hermes", name: "Hermes Runtime", description: "Experimental Hermes runtime integration for Fusion tasks (implementation deferred to FN-2264)", version: "0.1.0", }, factory: vi.fn().mockReturnValue(hermesPlaceholderRuntime), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-hermes-runtime", runtime: hermesRuntime as any }, ]); const result = pluginRunner.getRuntimeById("hermes"); expect(result).toBeDefined(); const context = { pluginId: "fusion-plugin-hermes-runtime" }; const runtime = await result!.runtime.factory(context as any) as typeof hermesPlaceholderRuntime; expect(hermesRuntime.factory).toHaveBeenCalledWith(context); expect(runtime).toBe(hermesPlaceholderRuntime); expect(runtime.runtimeId).toBe("hermes"); expect(runtime.status).toBe("deferred"); }); }); describe("OpenClaw runtime compatibility", () => { it("should resolve openclaw runtime when registered", () => { const openclawRuntime = { metadata: { runtimeId: "openclaw", name: "OpenClaw Runtime", description: "Experimental OpenClaw runtime integration for Fusion tasks (execution deferred)", version: "0.1.0", }, factory: vi.fn().mockReturnValue({}), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-openclaw-runtime", runtime: openclawRuntime as any }, ]); const result = pluginRunner.getRuntimeById("openclaw"); expect(result).toBeDefined(); expect(result?.pluginId).toBe("fusion-plugin-openclaw-runtime"); expect(result?.runtime.metadata.runtimeId).toBe("openclaw"); expect(result?.runtime.metadata.name).toBe("OpenClaw Runtime"); expect(result?.runtime.metadata.description).toBe( "Experimental OpenClaw runtime integration for Fusion tasks (execution deferred)", ); expect(result?.runtime.metadata.version).toBe("0.1.0"); }); it("should expose openclaw runtime metadata correctly", () => { const openclawRuntime = { metadata: { runtimeId: "openclaw", name: "OpenClaw Runtime", description: "Experimental OpenClaw runtime integration for Fusion tasks (execution deferred)", version: "0.1.0", }, factory: vi.fn().mockReturnValue({}), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-openclaw-runtime", runtime: openclawRuntime as any }, ]); const result = pluginRunner.getRuntimeById("openclaw"); expect(result).toBeDefined(); expect(result?.pluginId).toBe("fusion-plugin-openclaw-runtime"); expect(result?.runtime.metadata).toEqual({ runtimeId: "openclaw", name: "OpenClaw Runtime", description: "Experimental OpenClaw runtime integration for Fusion tasks (execution deferred)", version: "0.1.0", }); }); it("should return deferred placeholder object when openclaw factory is invoked", async () => { const openclawPlaceholderRuntime = { runtimeId: "openclaw", version: "0.1.0", status: "deferred", message: "OpenClaw runtime execution is currently deferred. This runtime is registered for discovery and configuration only.", execute: vi.fn().mockRejectedValue( new Error( "OpenClaw runtime is not implemented yet. Runtime discovery and configuration are supported, but execution is deferred.", ), ), }; const openclawRuntime = { metadata: { runtimeId: "openclaw", name: "OpenClaw Runtime", description: "Experimental OpenClaw runtime integration for Fusion tasks (execution deferred)", version: "0.1.0", }, factory: vi.fn().mockReturnValue(openclawPlaceholderRuntime), }; mockPluginLoader.getPluginRuntimes.mockReturnValue([ { pluginId: "fusion-plugin-openclaw-runtime", runtime: openclawRuntime as any }, ]); const result = pluginRunner.getRuntimeById("openclaw"); expect(result).toBeDefined(); const context = { pluginId: "fusion-plugin-openclaw-runtime" }; const runtime = await result!.runtime.factory(context as any) as typeof openclawPlaceholderRuntime; expect(openclawRuntime.factory).toHaveBeenCalledWith(context); expect(runtime).toBe(openclawPlaceholderRuntime); expect(runtime).toMatchObject({ runtimeId: "openclaw", version: "0.1.0", status: "deferred", }); expect(runtime.message).toContain("discovery and configuration only"); expect(runtime.execute).toBeTypeOf("function"); }); }); 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("logs warning when stopPlugin fails during plugin:unregistered handler", async () => { mockPluginLoader.stopPlugin.mockRejectedValue(new Error("stop failed")); await pluginRunner.init(); const unregisteredHandler = mockPluginStore.on.mock.calls.find( call => call[0] === "plugin:unregistered" )?.[1]; const logger = getPluginRunnerLogger(); logger.warn.mockClear(); expect(unregisteredHandler).toBeTypeOf("function"); const plugin = { id: "broken-plugin", name: "Broken Plugin", version: "1.0.0", path: "/test/path", enabled: false, state: "stopped" as const, settings: {}, createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z", }; await expect(unregisteredHandler?.(plugin)).resolves.toBeUndefined(); expect(mockPluginLoader.stopPlugin).toHaveBeenCalledWith("broken-plugin"); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("Failed to stop unregistered plugin broken-plugin: stop failed"), ); }); 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 }); }); });