/** * Pipeline Rebuild Tests * * Verifies that rebuilding the HookPipeline after plugin enable/disable * correctly includes/excludes hooks from the affected plugins. * * This tests the fix for #105: disabled plugins' hooks kept firing because * the pipeline was constructed once at startup and never rebuilt. */ import Database from "better-sqlite3"; import { Kysely, SqliteDialect } from "kysely"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createHookPipeline, resolveExclusiveHooks } from "../../../src/plugins/hooks.js"; import type { ResolvedPlugin, ResolvedHook, ContentHookEvent } from "../../../src/plugins/types.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createTestPlugin(overrides: Partial = {}): ResolvedPlugin { return { id: overrides.id ?? "test-plugin", version: "1.0.0", capabilities: [], allowedHosts: [], storage: {}, admin: { pages: [], widgets: [], }, hooks: {}, routes: {}, ...overrides, }; } function createTestHook( pluginId: string, handler: T, overrides: Partial> = {}, ): ResolvedHook { return { pluginId, handler, priority: 100, timeout: 5000, dependencies: [], errorPolicy: "continue", exclusive: false, ...overrides, }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { let sqlite: InstanceType; let db: Kysely>; beforeEach(() => { sqlite = new Database(":memory:"); db = new Kysely>({ dialect: new SqliteDialect({ database: sqlite }), }); }); afterEach(async () => { await db.destroy(); sqlite.close(); }); it("hooks from disabled plugin do not fire after pipeline rebuild", async () => { const handlerA = vi.fn(async (event: ContentHookEvent) => ({ ...event.content, pluginA: true, })); const handlerB = vi.fn(async (event: ContentHookEvent) => ({ ...event.content, pluginB: true, })); const pluginA = createTestPlugin({ id: "plugin-a", capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-a", handlerA), }, }); const pluginB = createTestPlugin({ id: "plugin-b", capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-b", handlerB), }, }); const allPlugins = [pluginA, pluginB]; // Initial pipeline with both plugins enabled const pipeline1 = createHookPipeline(allPlugins, { db }); expect(pipeline1.hasHooks("content:beforeSave")).toBe(true); expect(pipeline1.getHookCount("content:beforeSave")).toBe(2); // Run hooks — both should fire const result1 = await pipeline1.runContentBeforeSave({ title: "test" }, "posts", true); expect(handlerA).toHaveBeenCalledTimes(1); expect(handlerB).toHaveBeenCalledTimes(1); expect(result1.content).toEqual({ title: "test", pluginA: true, pluginB: true }); handlerA.mockClear(); handlerB.mockClear(); // Simulate disabling plugin-b: rebuild pipeline with only plugin-a const enabledPlugins = allPlugins.filter((p) => p.id !== "plugin-b"); const pipeline2 = createHookPipeline(enabledPlugins, { db }); expect(pipeline2.hasHooks("content:beforeSave")).toBe(true); expect(pipeline2.getHookCount("content:beforeSave")).toBe(1); // Run hooks — only plugin-a should fire const result2 = await pipeline2.runContentBeforeSave({ title: "test" }, "posts", true); expect(handlerA).toHaveBeenCalledTimes(1); expect(handlerB).not.toHaveBeenCalled(); expect(result2.content).toEqual({ title: "test", pluginA: true }); }); it("hooks from re-enabled plugin fire after pipeline rebuild", async () => { const handlerA = vi.fn(async (event: ContentHookEvent) => ({ ...event.content, pluginA: true, })); const handlerB = vi.fn(async (event: ContentHookEvent) => ({ ...event.content, pluginB: true, })); const pluginA = createTestPlugin({ id: "plugin-a", capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-a", handlerA), }, }); const pluginB = createTestPlugin({ id: "plugin-b", capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-b", handlerB), }, }); const allPlugins = [pluginA, pluginB]; // Start with only plugin-a (plugin-b is disabled) const pipeline1 = createHookPipeline([pluginA], { db }); const result1 = await pipeline1.runContentBeforeSave({ title: "test" }, "posts", true); expect(handlerA).toHaveBeenCalledTimes(1); expect(handlerB).not.toHaveBeenCalled(); expect(result1.content).toEqual({ title: "test", pluginA: true }); handlerA.mockClear(); // Re-enable plugin-b: rebuild pipeline with both const pipeline2 = createHookPipeline(allPlugins, { db }); const result2 = await pipeline2.runContentBeforeSave({ title: "test" }, "posts", true); expect(handlerA).toHaveBeenCalledTimes(1); expect(handlerB).toHaveBeenCalledTimes(1); expect(result2.content).toEqual({ title: "test", pluginA: true, pluginB: true }); }); it("exclusive hook selections are re-resolved after rebuild", async () => { const handlerA = vi.fn().mockResolvedValue(undefined); const handlerB = vi.fn().mockResolvedValue(undefined); const pluginA = createTestPlugin({ id: "provider-a", capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider-a", handlerA, { exclusive: true }), }, }); const pluginB = createTestPlugin({ id: "provider-b", capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider-b", handlerB, { exclusive: true }), }, }); // Both enabled — two providers, no auto-select const pipeline1 = createHookPipeline([pluginA, pluginB], { db }); expect(pipeline1.getExclusiveHookProviders("email:deliver")).toHaveLength(2); // Manually select provider-b (simulating admin selection) pipeline1.setExclusiveSelection("email:deliver", "provider-b"); expect(pipeline1.getExclusiveSelection("email:deliver")).toBe("provider-b"); // Disable provider-b: rebuild with only provider-a const pipeline2 = createHookPipeline([pluginA], { db }); expect(pipeline2.getExclusiveHookProviders("email:deliver")).toHaveLength(1); // Run exclusive hook resolution — should auto-select the sole provider const options = new Map(); await resolveExclusiveHooks({ pipeline: pipeline2, isActive: () => true, getOption: async (key) => options.get(key) ?? null, setOption: async (key, value) => { options.set(key, value); }, deleteOption: async (key) => { options.delete(key); }, }); expect(pipeline2.getExclusiveSelection("email:deliver")).toBe("provider-a"); }); it("disabling all plugins with a hook removes that hook entirely", async () => { const handler = vi.fn(async () => undefined); const plugin = createTestPlugin({ id: "only-plugin", capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("only-plugin", handler), }, }); // Pipeline with the plugin const pipeline1 = createHookPipeline([plugin], { db }); expect(pipeline1.hasHooks("content:beforeSave")).toBe(true); // Disable it: rebuild with empty list const pipeline2 = createHookPipeline([], { db }); expect(pipeline2.hasHooks("content:beforeSave")).toBe(false); expect(pipeline2.getHookCount("content:beforeSave")).toBe(0); }); it("lifecycle hooks for disabled plugin are excluded from pipeline", async () => { const installHandler = vi.fn(); const activateHandler = vi.fn(); const plugin = createTestPlugin({ id: "lifecycle-plugin", hooks: { "plugin:install": createTestHook("lifecycle-plugin", installHandler), "plugin:activate": createTestHook("lifecycle-plugin", activateHandler), }, }); // Pipeline with plugin const pipeline1 = createHookPipeline([plugin], { db }); expect(pipeline1.hasHooks("plugin:install")).toBe(true); expect(pipeline1.hasHooks("plugin:activate")).toBe(true); // Pipeline without plugin (disabled) const pipeline2 = createHookPipeline([], { db }); expect(pipeline2.hasHooks("plugin:install")).toBe(false); expect(pipeline2.hasHooks("plugin:activate")).toBe(false); }); it("plugin:activate fires when runPluginActivate is called after pipeline rebuild", async () => { const activateHandler = vi.fn().mockResolvedValue(undefined); const plugin = createTestPlugin({ id: "my-plugin", hooks: { "plugin:activate": createTestHook("my-plugin", activateHandler), }, }); // Simulate enabling: rebuild pipeline with plugin included, then invoke activate const pipeline = createHookPipeline([plugin], { db }); await pipeline.runPluginActivate("my-plugin"); expect(activateHandler).toHaveBeenCalledTimes(1); }); it("plugin:deactivate fires when runPluginDeactivate is called before pipeline rebuild", async () => { const deactivateHandler = vi.fn().mockResolvedValue(undefined); const plugin = createTestPlugin({ id: "my-plugin", hooks: { "plugin:deactivate": createTestHook("my-plugin", deactivateHandler), }, }); // Simulate disabling: invoke deactivate on the current pipeline, then rebuild without the plugin const pipeline = createHookPipeline([plugin], { db }); await pipeline.runPluginDeactivate("my-plugin"); expect(deactivateHandler).toHaveBeenCalledTimes(1); // Rebuild without the plugin — hook should no longer be registered const disabledPipeline = createHookPipeline([], { db }); expect(disabledPipeline.hasHooks("plugin:deactivate")).toBe(false); }); it("plugin:activate only fires for the targeted plugin, not others", async () => { const activateA = vi.fn().mockResolvedValue(undefined); const activateB = vi.fn().mockResolvedValue(undefined); const pluginA = createTestPlugin({ id: "plugin-a", hooks: { "plugin:activate": createTestHook("plugin-a", activateA), }, }); const pluginB = createTestPlugin({ id: "plugin-b", hooks: { "plugin:activate": createTestHook("plugin-b", activateB), }, }); const pipeline = createHookPipeline([pluginA, pluginB], { db }); // Enabling only plugin-a should not fire plugin-b's activate await pipeline.runPluginActivate("plugin-a"); expect(activateA).toHaveBeenCalledTimes(1); expect(activateB).not.toHaveBeenCalled(); }); });