Files
emdash-patch-imageupload/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

333 lines
10 KiB
TypeScript

/**
* 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> = {}): ResolvedPlugin {
return {
id: overrides.id ?? "test-plugin",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storage: {},
admin: {
pages: [],
widgets: [],
},
hooks: {},
routes: {},
...overrides,
};
}
function createTestHook<T>(
pluginId: string,
handler: T,
overrides: Partial<ResolvedHook<T>> = {},
): ResolvedHook<T> {
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<typeof Database>;
let db: Kysely<Record<string, unknown>>;
beforeEach(() => {
sqlite = new Database(":memory:");
db = new Kysely<Record<string, unknown>>({
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<string, string>();
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();
});
});