b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
1048 lines
33 KiB
TypeScript
1048 lines
33 KiB
TypeScript
/**
|
|
* Exclusive Hooks Tests
|
|
*
|
|
* Tests the exclusive hook system:
|
|
* - HookPipeline: registration/tracking, selection, invokeExclusiveHook
|
|
* - PluginManager.resolveExclusiveHooks(): single provider auto-select,
|
|
* multi-provider no auto-select, stale selection clearing, preferred hints,
|
|
* admin override beats preferred
|
|
* - Lifecycle: activate → auto-select, deactivate → clears stale selection
|
|
*/
|
|
|
|
import Database from "better-sqlite3";
|
|
import { Kysely, SqliteDialect } from "kysely";
|
|
import type { KyselyPlugin } from "kysely";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
import { extractManifest } from "../../../src/cli/commands/bundle-utils.js";
|
|
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
|
import type { Database as DbSchema } from "../../../src/database/types.js";
|
|
import { HookPipeline, resolveExclusiveHooks } from "../../../src/plugins/hooks.js";
|
|
import { PluginManager } from "../../../src/plugins/manager.js";
|
|
import { normalizeManifestHook } from "../../../src/plugins/manifest-schema.js";
|
|
import type {
|
|
ResolvedPlugin,
|
|
ResolvedHook,
|
|
PluginDefinition,
|
|
ContentBeforeSaveHandler,
|
|
ContentAfterSaveHandler,
|
|
ContentBeforeDeleteHandler,
|
|
} from "../../../src/plugins/types.js";
|
|
import { describeEachDialect, setupForDialect, teardownForDialect } from "../../utils/test-db.js";
|
|
import type { DialectTestContext } from "../../utils/test-db.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers — ResolvedPlugin (for HookPipeline tests)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
|
return {
|
|
id: overrides.id ?? "test-plugin",
|
|
version: "1.0.0",
|
|
capabilities: ["content:write", "content:read"],
|
|
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,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers — PluginDefinition (for PluginManager tests)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createTestDefinition(overrides: Partial<PluginDefinition> = {}): PluginDefinition {
|
|
return {
|
|
id: overrides.id ?? "test-plugin",
|
|
version: "1.0.0",
|
|
capabilities: ["content:write", "content:read"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HookPipeline — exclusive behaviour
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("HookPipeline — exclusive hooks", () => {
|
|
it("tracks exclusive hook names during registration", () => {
|
|
const plugin = createTestPlugin({
|
|
id: "email-provider",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("email-provider", vi.fn(), {
|
|
exclusive: true,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin]);
|
|
|
|
expect(pipeline.isExclusiveHook("content:beforeSave")).toBe(true);
|
|
expect(pipeline.isExclusiveHook("content:afterSave")).toBe(false);
|
|
expect(pipeline.getRegisteredExclusiveHooks()).toContain("content:beforeSave");
|
|
});
|
|
|
|
it("does not track non-exclusive hooks as exclusive", () => {
|
|
const plugin = createTestPlugin({
|
|
id: "normal-plugin",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("normal-plugin", vi.fn(), {
|
|
exclusive: false,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin]);
|
|
|
|
expect(pipeline.isExclusiveHook("content:beforeSave")).toBe(false);
|
|
expect(pipeline.getRegisteredExclusiveHooks()).not.toContain("content:beforeSave");
|
|
});
|
|
|
|
it("returns providers for an exclusive hook", () => {
|
|
const plugin1 = createTestPlugin({
|
|
id: "provider-a",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const plugin2 = createTestPlugin({
|
|
id: "provider-b",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("provider-b", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin1, plugin2]);
|
|
|
|
const providers = pipeline.getExclusiveHookProviders("content:beforeSave");
|
|
expect(providers).toHaveLength(2);
|
|
expect(providers.map((p) => p.pluginId)).toEqual(
|
|
expect.arrayContaining(["provider-a", "provider-b"]),
|
|
);
|
|
});
|
|
|
|
it("set/get/clear exclusive selection", () => {
|
|
const plugin = createTestPlugin({
|
|
id: "email-ses",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("email-ses", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin]);
|
|
|
|
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBeUndefined();
|
|
|
|
pipeline.setExclusiveSelection("content:beforeSave", "email-ses");
|
|
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("email-ses");
|
|
|
|
pipeline.clearExclusiveSelection("content:beforeSave");
|
|
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBeUndefined();
|
|
});
|
|
|
|
it("invokeExclusiveHook returns null when no selection", async () => {
|
|
const handler = vi.fn().mockResolvedValue("result");
|
|
const plugin = createTestPlugin({
|
|
id: "provider-a",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("provider-a", handler, { exclusive: true }),
|
|
},
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin]);
|
|
|
|
const result = await pipeline.invokeExclusiveHook("content:beforeSave", { some: "event" });
|
|
expect(result).toBeNull();
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("invokeExclusiveHook dispatches only to selected provider", async () => {
|
|
const handlerA = vi.fn().mockResolvedValue("result-a");
|
|
const handlerB = vi.fn().mockResolvedValue("result-b");
|
|
|
|
const pluginA = createTestPlugin({
|
|
id: "provider-a",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("provider-a", handlerA, { exclusive: true }),
|
|
},
|
|
});
|
|
const pluginB = createTestPlugin({
|
|
id: "provider-b",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("provider-b", handlerB, { exclusive: true }),
|
|
},
|
|
});
|
|
|
|
// Context factory needs a db for PluginContextFactory
|
|
const sqlite = new Database(":memory:");
|
|
const db = new Kysely<DbSchema>({
|
|
dialect: new SqliteDialect({ database: sqlite }),
|
|
});
|
|
|
|
const pipeline = new HookPipeline([pluginA, pluginB], { db });
|
|
|
|
pipeline.setExclusiveSelection("content:afterSave", "provider-b");
|
|
|
|
const result = await pipeline.invokeExclusiveHook("content:afterSave", { some: "event" });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.pluginId).toBe("provider-b");
|
|
expect(result!.result).toBe("result-b");
|
|
|
|
expect(handlerB).toHaveBeenCalledTimes(1);
|
|
expect(handlerA).not.toHaveBeenCalled();
|
|
|
|
await db.destroy();
|
|
sqlite.close();
|
|
});
|
|
|
|
it("invokeExclusiveHook isolates errors — returns error result instead of throwing", async () => {
|
|
const handler = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error("provider crashed")) as unknown as ContentAfterSaveHandler;
|
|
|
|
const plugin = createTestPlugin({
|
|
id: "broken-provider",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("broken-provider", handler, {
|
|
exclusive: true,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const sqlite = new Database(":memory:");
|
|
const db = new Kysely<DbSchema>({
|
|
dialect: new SqliteDialect({ database: sqlite }),
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin], { db });
|
|
pipeline.setExclusiveSelection("content:afterSave", "broken-provider");
|
|
|
|
// Should NOT throw — error is isolated
|
|
const result = await pipeline.invokeExclusiveHook("content:afterSave", {});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.pluginId).toBe("broken-provider");
|
|
expect(result!.error).toBeInstanceOf(Error);
|
|
expect(result!.error!.message).toBe("provider crashed");
|
|
expect(result!.result).toBeUndefined();
|
|
expect(result!.duration).toBeGreaterThanOrEqual(0);
|
|
|
|
await db.destroy();
|
|
sqlite.close();
|
|
});
|
|
|
|
it("invokeExclusiveHook respects timeout", async () => {
|
|
const handler = vi.fn(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
setTimeout(resolve, 10_000);
|
|
}),
|
|
) as unknown as ContentAfterSaveHandler;
|
|
|
|
const plugin = createTestPlugin({
|
|
id: "slow-provider",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("slow-provider", handler, {
|
|
exclusive: true,
|
|
timeout: 50,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const sqlite = new Database(":memory:");
|
|
const db = new Kysely<DbSchema>({
|
|
dialect: new SqliteDialect({ database: sqlite }),
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin], { db });
|
|
pipeline.setExclusiveSelection("content:afterSave", "slow-provider");
|
|
|
|
const result = await pipeline.invokeExclusiveHook("content:afterSave", {});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.error).toBeInstanceOf(Error);
|
|
expect(result!.error!.message.toLowerCase()).toContain("timeout");
|
|
|
|
await db.destroy();
|
|
sqlite.close();
|
|
});
|
|
|
|
it("exclusive hooks with a selection are skipped in regular pipeline", async () => {
|
|
const exclusiveHandler = vi.fn().mockResolvedValue(undefined);
|
|
const normalHandler = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const exclusivePlugin = createTestPlugin({
|
|
id: "exclusive-plugin",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("exclusive-plugin", exclusiveHandler, {
|
|
exclusive: true,
|
|
}),
|
|
},
|
|
});
|
|
const normalPlugin = createTestPlugin({
|
|
id: "normal-plugin",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("normal-plugin", normalHandler, {
|
|
exclusive: false,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const sqlite = new Database(":memory:");
|
|
const db = new Kysely<DbSchema>({
|
|
dialect: new SqliteDialect({ database: sqlite }),
|
|
});
|
|
|
|
const pipeline = new HookPipeline([exclusivePlugin, normalPlugin], { db });
|
|
|
|
// Set a selection — this means the exclusive hook should NOT run in the regular pipeline
|
|
pipeline.setExclusiveSelection("content:afterSave", "exclusive-plugin");
|
|
|
|
await pipeline.runContentAfterSave({ title: "test" }, "posts", true);
|
|
|
|
// Normal hook should run
|
|
expect(normalHandler).toHaveBeenCalledTimes(1);
|
|
// Exclusive hook should NOT have run in the regular pipeline
|
|
expect(exclusiveHandler).not.toHaveBeenCalled();
|
|
|
|
await db.destroy();
|
|
sqlite.close();
|
|
});
|
|
|
|
it("exclusive hooks without a selection DO run in regular pipeline", async () => {
|
|
const exclusiveHandler = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const plugin = createTestPlugin({
|
|
id: "unselected-provider",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("unselected-provider", exclusiveHandler, {
|
|
exclusive: true,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const sqlite = new Database(":memory:");
|
|
const db = new Kysely<DbSchema>({
|
|
dialect: new SqliteDialect({ database: sqlite }),
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin], { db });
|
|
|
|
// No selection set — exclusive hooks should still run in regular pipeline
|
|
await pipeline.runContentAfterSave({ title: "test" }, "posts", true);
|
|
|
|
expect(exclusiveHandler).toHaveBeenCalledTimes(1);
|
|
|
|
await db.destroy();
|
|
sqlite.close();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HookPipeline — non-exclusive provider enumeration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("HookPipeline — getHookProviders", () => {
|
|
it("returns non-exclusive providers registered for a hook", () => {
|
|
const plugin1 = createTestPlugin({
|
|
id: "middleware-a",
|
|
capabilities: ["hooks.email-events:register"],
|
|
hooks: {
|
|
"email:beforeSend": createTestHook("middleware-a", vi.fn()),
|
|
},
|
|
});
|
|
const plugin2 = createTestPlugin({
|
|
id: "middleware-b",
|
|
capabilities: ["hooks.email-events:register"],
|
|
hooks: {
|
|
"email:beforeSend": createTestHook("middleware-b", vi.fn()),
|
|
},
|
|
});
|
|
|
|
const pipeline = new HookPipeline([plugin1, plugin2]);
|
|
|
|
const providers = pipeline.getHookProviders("email:beforeSend");
|
|
expect(providers.map((p) => p.pluginId)).toEqual(
|
|
expect.arrayContaining(["middleware-a", "middleware-b"]),
|
|
);
|
|
expect(providers).toHaveLength(2);
|
|
});
|
|
|
|
it("partitions with getExclusiveHookProviders — excludes exclusive registrations", () => {
|
|
const exclusivePlugin = createTestPlugin({
|
|
id: "exclusive-provider",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("exclusive-provider", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const nonExclusivePlugin = createTestPlugin({
|
|
id: "non-exclusive-provider",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("non-exclusive-provider", vi.fn()),
|
|
},
|
|
});
|
|
|
|
const pipeline = new HookPipeline([exclusivePlugin, nonExclusivePlugin]);
|
|
|
|
expect(pipeline.getHookProviders("content:beforeSave").map((p) => p.pluginId)).toEqual([
|
|
"non-exclusive-provider",
|
|
]);
|
|
expect(pipeline.getExclusiveHookProviders("content:beforeSave").map((p) => p.pluginId)).toEqual(
|
|
["exclusive-provider"],
|
|
);
|
|
});
|
|
|
|
it("returns empty array for an unregistered hook", () => {
|
|
const pipeline = new HookPipeline([]);
|
|
expect(pipeline.getHookProviders("email:beforeSend")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// normalizeManifestHook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("normalizeManifestHook", () => {
|
|
it("converts a plain string to an object", () => {
|
|
const result = normalizeManifestHook("content:beforeSave");
|
|
expect(result).toEqual({ name: "content:beforeSave" });
|
|
});
|
|
|
|
it("passes through an object unchanged", () => {
|
|
const entry = { name: "content:beforeSave", exclusive: true, priority: 50 };
|
|
const result = normalizeManifestHook(entry);
|
|
expect(result).toEqual(entry);
|
|
});
|
|
|
|
it("handles object with only name", () => {
|
|
const result = normalizeManifestHook({ name: "media:afterUpload" });
|
|
expect(result).toEqual({ name: "media:afterUpload" });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// extractManifest — exclusive hook metadata
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("extractManifest — exclusive hooks", () => {
|
|
it("emits plain hook names for non-exclusive hooks with default settings", () => {
|
|
const plugin = createTestPlugin({
|
|
id: "simple-plugin",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("simple-plugin", vi.fn()),
|
|
},
|
|
});
|
|
|
|
const manifest = extractManifest(plugin);
|
|
expect(manifest.hooks).toEqual(["content:beforeSave"]);
|
|
});
|
|
|
|
it("emits structured entries for exclusive hooks", () => {
|
|
const plugin = createTestPlugin({
|
|
id: "email-provider",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("email-provider", vi.fn(), {
|
|
exclusive: true,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const manifest = extractManifest(plugin);
|
|
expect(manifest.hooks).toEqual([{ name: "content:beforeSave", exclusive: true }]);
|
|
});
|
|
|
|
it("emits structured entries for hooks with custom priority or timeout", () => {
|
|
const plugin = createTestPlugin({
|
|
id: "custom-plugin",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("custom-plugin", vi.fn(), {
|
|
priority: 50,
|
|
timeout: 10000,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const manifest = extractManifest(plugin);
|
|
expect(manifest.hooks).toEqual([{ name: "content:afterSave", priority: 50, timeout: 10000 }]);
|
|
});
|
|
|
|
it("handles mixed exclusive and non-exclusive hooks", () => {
|
|
const plugin = createTestPlugin({
|
|
id: "mixed-plugin",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("mixed-plugin", vi.fn(), { exclusive: true }),
|
|
"content:afterSave": createTestHook("mixed-plugin", vi.fn()),
|
|
},
|
|
});
|
|
|
|
const manifest = extractManifest(plugin);
|
|
expect(manifest.hooks).toHaveLength(2);
|
|
|
|
// One should be structured (exclusive), one should be a plain string
|
|
const structured = manifest.hooks.filter((h) => typeof h === "object");
|
|
const plain = manifest.hooks.filter((h) => typeof h === "string");
|
|
expect(structured).toHaveLength(1);
|
|
expect(plain).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveExclusiveHooks (shared function)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("resolveExclusiveHooks — shared function", () => {
|
|
it("auto-selects single active provider", async () => {
|
|
const plugin = createTestPlugin({
|
|
id: "only-provider",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("only-provider", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const pipeline = new HookPipeline([plugin]);
|
|
|
|
const store = new Map<string, string>();
|
|
|
|
await resolveExclusiveHooks({
|
|
pipeline,
|
|
isActive: () => true,
|
|
getOption: async (key) => store.get(key) ?? null,
|
|
setOption: async (key, value) => {
|
|
store.set(key, value);
|
|
},
|
|
deleteOption: async (key) => {
|
|
store.delete(key);
|
|
},
|
|
});
|
|
|
|
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("only-provider");
|
|
});
|
|
|
|
it("filters out inactive providers", async () => {
|
|
const pluginA = createTestPlugin({
|
|
id: "active-provider",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("active-provider", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const pluginB = createTestPlugin({
|
|
id: "inactive-provider",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("inactive-provider", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const pipeline = new HookPipeline([pluginA, pluginB]);
|
|
|
|
const store = new Map<string, string>();
|
|
|
|
await resolveExclusiveHooks({
|
|
pipeline,
|
|
isActive: (id) => id === "active-provider",
|
|
getOption: async (key) => store.get(key) ?? null,
|
|
setOption: async (key, value) => {
|
|
store.set(key, value);
|
|
},
|
|
deleteOption: async (key) => {
|
|
store.delete(key);
|
|
},
|
|
});
|
|
|
|
// Only active-provider is active, so it should be auto-selected
|
|
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("active-provider");
|
|
});
|
|
|
|
it("clears stale selection when selected provider is inactive", async () => {
|
|
const pluginA = createTestPlugin({
|
|
id: "provider-a",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const pluginB = createTestPlugin({
|
|
id: "provider-b",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("provider-b", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const pipeline = new HookPipeline([pluginA, pluginB]);
|
|
|
|
// Simulate existing selection for provider-a which is now inactive
|
|
const store = new Map<string, string>([
|
|
["emdash:exclusive_hook:content:beforeSave", "provider-a"],
|
|
]);
|
|
|
|
await resolveExclusiveHooks({
|
|
pipeline,
|
|
isActive: (id) => id === "provider-b", // provider-a is inactive
|
|
getOption: async (key) => store.get(key) ?? null,
|
|
setOption: async (key, value) => {
|
|
store.set(key, value);
|
|
},
|
|
deleteOption: async (key) => {
|
|
store.delete(key);
|
|
},
|
|
});
|
|
|
|
// provider-a was stale, cleared. provider-b is the only active one → auto-selected
|
|
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("provider-b");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PluginManager — resolveExclusiveHooks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("PluginManager — resolveExclusiveHooks", () => {
|
|
let db: Kysely<DbSchema>;
|
|
let sqliteDb: Database.Database;
|
|
|
|
beforeEach(async () => {
|
|
sqliteDb = new Database(":memory:");
|
|
db = new Kysely<DbSchema>({
|
|
dialect: new SqliteDialect({ database: sqliteDb }),
|
|
});
|
|
await runMigrations(db);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.destroy();
|
|
sqliteDb.close();
|
|
});
|
|
|
|
it("auto-selects when only one provider for an exclusive hook", async () => {
|
|
const handler = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
|
|
const manager = new PluginManager({ db });
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "email-ses",
|
|
hooks: {
|
|
"content:beforeSave": { handler, exclusive: true },
|
|
},
|
|
}),
|
|
);
|
|
await manager.activate("email-ses");
|
|
|
|
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
|
expect(selection).toBe("email-ses");
|
|
});
|
|
|
|
it("keeps auto-selected provider when a second provider activates", async () => {
|
|
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
|
|
const manager = new PluginManager({ db });
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-a",
|
|
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
|
}),
|
|
);
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-b",
|
|
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
|
}),
|
|
);
|
|
|
|
// provider-a is the only one — gets auto-selected
|
|
await manager.activate("provider-a");
|
|
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
|
|
|
// provider-b activates — existing valid selection is preserved
|
|
await manager.activate("provider-b");
|
|
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
|
});
|
|
|
|
it("leaves unselected when multiple providers activate simultaneously", async () => {
|
|
// If no one was auto-selected before the second provider, there's no
|
|
// selection to keep. Test this by registering both before activating.
|
|
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
|
|
const manager = new PluginManager({ db });
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-a",
|
|
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
|
}),
|
|
);
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-b",
|
|
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
|
}),
|
|
);
|
|
|
|
// Activate provider-a (auto-selects as sole provider)
|
|
await manager.activate("provider-a");
|
|
// Clear the auto-selection to simulate "no prior selection"
|
|
await manager.setExclusiveHookSelection("content:beforeSave", null);
|
|
|
|
// Now activate provider-b — both active, no existing selection
|
|
await manager.activate("provider-b");
|
|
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
|
expect(selection).toBeNull();
|
|
});
|
|
|
|
it("clears stale selection when selected plugin is deactivated", async () => {
|
|
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
|
|
const manager = new PluginManager({ db });
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-a",
|
|
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
|
}),
|
|
);
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-b",
|
|
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
|
}),
|
|
);
|
|
|
|
await manager.activate("provider-a");
|
|
await manager.activate("provider-b");
|
|
|
|
// Manually set a selection
|
|
await manager.setExclusiveHookSelection("content:beforeSave", "provider-a");
|
|
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
|
|
|
// Deactivate the selected plugin
|
|
await manager.deactivate("provider-a");
|
|
|
|
// After deactivation, provider-b is the only one left → auto-selects
|
|
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
|
expect(selection).toBe("provider-b");
|
|
});
|
|
|
|
it("uses preferred hints when no selection exists", async () => {
|
|
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
|
|
const manager = new PluginManager({ db });
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-a",
|
|
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
|
}),
|
|
);
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-b",
|
|
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
|
}),
|
|
);
|
|
|
|
await manager.activate("provider-a");
|
|
await manager.activate("provider-b");
|
|
|
|
// Clear any auto-selection from the first activate
|
|
await manager.setExclusiveHookSelection("content:beforeSave", null);
|
|
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBeNull();
|
|
|
|
// Resolve with preferred hint
|
|
const hints = new Map([["provider-b", ["content:beforeSave"]]]);
|
|
await manager.resolveExclusiveHooks(hints);
|
|
|
|
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-b");
|
|
});
|
|
|
|
it("admin override (DB selection) beats preferred hints", async () => {
|
|
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
|
|
const manager = new PluginManager({ db });
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-a",
|
|
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
|
}),
|
|
);
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-b",
|
|
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
|
}),
|
|
);
|
|
|
|
await manager.activate("provider-a");
|
|
await manager.activate("provider-b");
|
|
|
|
// Admin explicitly sets provider-a
|
|
await manager.setExclusiveHookSelection("content:beforeSave", "provider-a");
|
|
|
|
// Resolve with preferred hint for provider-b — admin choice should win
|
|
const hints = new Map([["provider-b", ["content:beforeSave"]]]);
|
|
await manager.resolveExclusiveHooks(hints);
|
|
|
|
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
|
});
|
|
|
|
it("getExclusiveHooksInfo returns complete info", async () => {
|
|
const handler = vi.fn() as unknown as ContentBeforeSaveHandler;
|
|
|
|
const manager = new PluginManager({ db });
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-a",
|
|
hooks: { "content:beforeSave": { handler, exclusive: true } },
|
|
}),
|
|
);
|
|
await manager.activate("provider-a");
|
|
|
|
const info = await manager.getExclusiveHooksInfo();
|
|
expect(info).toHaveLength(1);
|
|
expect(info[0]!.hookName).toBe("content:beforeSave");
|
|
expect(info[0]!.providers).toHaveLength(1);
|
|
expect(info[0]!.providers[0]!.pluginId).toBe("provider-a");
|
|
expect(info[0]!.selectedPluginId).toBe("provider-a");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveExclusiveHooks — batched option reads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("resolveExclusiveHooks — batched option reads", () => {
|
|
/**
|
|
* Three plugins / three exclusive hooks covering every resolution branch:
|
|
* - content:beforeSave: providers a+b active, valid stored selection (kept)
|
|
* - content:afterSave: provider c stale (inactive), a remains (auto-select)
|
|
* - content:beforeDelete: providers a+b active, no selection (unselected)
|
|
*/
|
|
function createScenarioPipeline(): HookPipeline {
|
|
const pluginA = createTestPlugin({
|
|
id: "provider-a",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
|
"content:afterSave": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
|
"content:beforeDelete": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const pluginB = createTestPlugin({
|
|
id: "provider-b",
|
|
hooks: {
|
|
"content:beforeSave": createTestHook("provider-b", vi.fn(), { exclusive: true }),
|
|
"content:beforeDelete": createTestHook("provider-b", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
const pluginC = createTestPlugin({
|
|
id: "provider-c",
|
|
hooks: {
|
|
"content:afterSave": createTestHook("provider-c", vi.fn(), { exclusive: true }),
|
|
},
|
|
});
|
|
return new HookPipeline([pluginA, pluginB, pluginC]);
|
|
}
|
|
|
|
function createScenarioStore(): Map<string, string> {
|
|
return new Map([
|
|
["emdash:exclusive_hook:content:beforeSave", "provider-a"],
|
|
["emdash:exclusive_hook:content:afterSave", "provider-c"],
|
|
]);
|
|
}
|
|
|
|
function createStoreCallbacks(store: Map<string, string>) {
|
|
return {
|
|
getOption: vi.fn(async (key: string): Promise<string | null> => store.get(key) ?? null),
|
|
getOptions: vi.fn(async (keys: string[]): Promise<ReadonlyMap<string, string>> => {
|
|
const result = new Map<string, string>();
|
|
for (const key of keys) {
|
|
const value = store.get(key);
|
|
if (value !== undefined) result.set(key, value);
|
|
}
|
|
return result;
|
|
}),
|
|
setOption: vi.fn(async (key: string, value: string) => {
|
|
store.set(key, value);
|
|
}),
|
|
deleteOption: vi.fn(async (key: string) => {
|
|
store.delete(key);
|
|
}),
|
|
};
|
|
}
|
|
|
|
const isActive = (id: string) => id !== "provider-c";
|
|
|
|
it("reads all selections with one getOptions call and no per-hook gets", async () => {
|
|
const pipeline = createScenarioPipeline();
|
|
const store = createScenarioStore();
|
|
const callbacks = createStoreCallbacks(store);
|
|
|
|
await resolveExclusiveHooks({ pipeline, isActive, ...callbacks });
|
|
|
|
expect(callbacks.getOptions).toHaveBeenCalledTimes(1);
|
|
expect(callbacks.getOptions.mock.calls[0]![0]).toEqual(
|
|
expect.arrayContaining([
|
|
"emdash:exclusive_hook:content:beforeSave",
|
|
"emdash:exclusive_hook:content:afterSave",
|
|
"emdash:exclusive_hook:content:beforeDelete",
|
|
]),
|
|
);
|
|
expect(callbacks.getOption).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves identically to the per-key path", async () => {
|
|
// Batched path
|
|
const batchedPipeline = createScenarioPipeline();
|
|
const batchedStore = createScenarioStore();
|
|
await resolveExclusiveHooks({
|
|
pipeline: batchedPipeline,
|
|
isActive,
|
|
...createStoreCallbacks(batchedStore),
|
|
});
|
|
|
|
// Per-key path (no getOptions)
|
|
const perKeyPipeline = createScenarioPipeline();
|
|
const perKeyStore = createScenarioStore();
|
|
const { getOptions: _unused, ...perKeyCallbacks } = createStoreCallbacks(perKeyStore);
|
|
await resolveExclusiveHooks({
|
|
pipeline: perKeyPipeline,
|
|
isActive,
|
|
...perKeyCallbacks,
|
|
});
|
|
|
|
for (const hookName of ["content:beforeSave", "content:afterSave", "content:beforeDelete"]) {
|
|
expect(batchedPipeline.getExclusiveSelection(hookName)).toBe(
|
|
perKeyPipeline.getExclusiveSelection(hookName),
|
|
);
|
|
}
|
|
expect(batchedStore).toEqual(perKeyStore);
|
|
|
|
// Sanity-check the actual outcomes, not just parity
|
|
expect(batchedPipeline.getExclusiveSelection("content:beforeSave")).toBe("provider-a");
|
|
expect(batchedPipeline.getExclusiveSelection("content:afterSave")).toBe("provider-a");
|
|
expect(batchedPipeline.getExclusiveSelection("content:beforeDelete")).toBeUndefined();
|
|
});
|
|
|
|
it("skips resolution when the batch read fails (options table not ready)", async () => {
|
|
const pipeline = createScenarioPipeline();
|
|
const callbacks = createStoreCallbacks(createScenarioStore());
|
|
callbacks.getOptions.mockRejectedValue(new Error("no such table: options"));
|
|
|
|
await expect(
|
|
resolveExclusiveHooks({ pipeline, isActive, ...callbacks }),
|
|
).resolves.toBeUndefined();
|
|
|
|
// Matches the per-key tolerance: nothing written, nothing selected
|
|
expect(callbacks.setOption).not.toHaveBeenCalled();
|
|
expect(callbacks.deleteOption).not.toHaveBeenCalled();
|
|
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBeUndefined();
|
|
expect(pipeline.getExclusiveSelection("content:afterSave")).toBeUndefined();
|
|
expect(pipeline.getExclusiveSelection("content:beforeDelete")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PluginManager — batched resolution against a real database
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Kysely plugin that counts compiled SELECT statements — one per executed
|
|
* select round trip. Used to assert the batched resolution issues a single
|
|
* options read regardless of how many exclusive hooks are registered.
|
|
*/
|
|
function createSelectCountingPlugin(): { plugin: KyselyPlugin; counter: { count: number } } {
|
|
const counter = { count: 0 };
|
|
const plugin: KyselyPlugin = {
|
|
transformQuery(args) {
|
|
if (args.node.kind === "SelectQueryNode") counter.count += 1;
|
|
return args.node;
|
|
},
|
|
transformResult(args) {
|
|
return Promise.resolve(args.result);
|
|
},
|
|
};
|
|
return { plugin, counter };
|
|
}
|
|
|
|
describeEachDialect("PluginManager — batched exclusive hook resolution", (dialect) => {
|
|
let ctx: DialectTestContext;
|
|
|
|
beforeEach(async () => {
|
|
ctx = await setupForDialect(dialect);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownForDialect(ctx);
|
|
});
|
|
|
|
it("resolves all exclusive hooks with a single options select", async () => {
|
|
const { plugin: countingPlugin, counter } = createSelectCountingPlugin();
|
|
const db = ctx.db.withPlugin(countingPlugin);
|
|
|
|
const manager = new PluginManager({ db });
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-save",
|
|
hooks: {
|
|
"content:beforeSave": {
|
|
handler: vi.fn() as unknown as ContentBeforeSaveHandler,
|
|
exclusive: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-after",
|
|
hooks: {
|
|
"content:afterSave": {
|
|
handler: vi.fn() as unknown as ContentAfterSaveHandler,
|
|
exclusive: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
manager.register(
|
|
createTestDefinition({
|
|
id: "provider-delete",
|
|
hooks: {
|
|
"content:beforeDelete": {
|
|
handler: vi.fn() as unknown as ContentBeforeDeleteHandler,
|
|
exclusive: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await manager.activate("provider-save");
|
|
await manager.activate("provider-after");
|
|
await manager.activate("provider-delete");
|
|
|
|
// All three sole providers were auto-selected during activation;
|
|
// a fresh resolution keeps them with exactly one batched read.
|
|
counter.count = 0;
|
|
await manager.resolveExclusiveHooks();
|
|
expect(counter.count).toBe(1);
|
|
|
|
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-save");
|
|
expect(await manager.getExclusiveHookSelection("content:afterSave")).toBe("provider-after");
|
|
expect(await manager.getExclusiveHookSelection("content:beforeDelete")).toBe("provider-delete");
|
|
});
|
|
});
|