Files
fusion/packages/core/src/plugin-store.test.ts
gsxdsm f045c438cd feat(FN-1111): merge fusion/fn-1111 (auto-resolved)
- docs(FN-1111): complete Step 8 - plugin system documentation
- feat(FN-1111): complete Step 7 - testing and build fixes
- feat(FN-1111): complete Step 6 - export plugin types from core
- feat(FN-1111): complete Step 5 - plugin SDK package
- feat(FN-1111): complete Step 4 - plugin loader with lifecycle management
- feat(FN-1111): complete Step 3 - plugin store with CRUD
- feat(FN-1111): complete Step 2 - add plugins table schema migration
- feat(FN-1111): complete Step 1 - plugin type definitions
2026-04-09 14:45:32 -07:00

585 lines
20 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { PluginStore } from "./plugin-store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import type { PluginManifest, PluginState } from "./plugin-types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-plugin-test-"));
}
function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
return {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
description: "A test plugin",
...overrides,
};
}
describe("PluginStore", () => {
let rootDir: string;
let store: PluginStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new PluginStore(rootDir);
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
// ── init ──────────────────────────────────────────────────────────
describe("init", () => {
it("creates the database file", async () => {
const dbPath = join(rootDir, ".fusion", "fusion.db");
const { existsSync } = await import("node:fs");
expect(existsSync(dbPath)).toBe(true);
});
it("is idempotent", async () => {
await store.init();
await store.init();
// Should not throw
const plugins = await store.listPlugins();
expect(plugins).toEqual([]);
});
it("creates the plugins table", async () => {
// If the table doesn't exist, listPlugins would fail
const plugins = await store.listPlugins();
expect(Array.isArray(plugins)).toBe(true);
});
});
// ── registerPlugin ─────────────────────────────────────────────────
describe("registerPlugin", () => {
it("registers a valid plugin and returns full record", async () => {
const manifest = makeManifest();
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
expect(plugin.id).toBe("test-plugin");
expect(plugin.name).toBe("Test Plugin");
expect(plugin.version).toBe("1.0.0");
expect(plugin.description).toBe("A test plugin");
expect(plugin.path).toBe("/path/to/plugin");
expect(plugin.enabled).toBe(true);
expect(plugin.state).toBe("installed");
expect(plugin.settings).toEqual({});
expect(plugin.dependencies).toEqual([]);
expect(plugin.createdAt).toBeTruthy();
expect(plugin.updatedAt).toBeTruthy();
});
it("registers plugin with custom settings", async () => {
const manifest = makeManifest();
const plugin = await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: { apiKey: "secret123", maxItems: 10 },
});
expect(plugin.settings).toEqual({ apiKey: "secret123", maxItems: 10 });
});
it("registers plugin with dependencies", async () => {
const manifest = makeManifest({ dependencies: ["other-plugin"] });
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
expect(plugin.dependencies).toEqual(["other-plugin"]);
});
it("registers plugin with settings schema", async () => {
const manifest = makeManifest({
settingsSchema: {
apiKey: { type: "string", required: true },
count: { type: "number", defaultValue: 5 },
},
});
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
expect(plugin.settingsSchema).toBeTruthy();
expect(plugin.settingsSchema!.apiKey.type).toBe("string");
expect(plugin.settingsSchema!.count.defaultValue).toBe(5);
});
it("rejects missing manifest id", async () => {
const manifest = makeManifest({ id: "" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects missing manifest name", async () => {
const manifest = makeManifest({ name: "" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects missing manifest version", async () => {
const manifest = makeManifest({ version: "" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects invalid id format (uppercase)", async () => {
const manifest = makeManifest({ id: "Test-Plugin" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects invalid id format (underscores)", async () => {
const manifest = makeManifest({ id: "test_plugin" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects invalid id format (starts with hyphen)", async () => {
const manifest = makeManifest({ id: "-test-plugin" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
).rejects.toThrow("Invalid plugin manifest");
});
it("rejects empty path", async () => {
const manifest = makeManifest({ id: "valid-plugin" });
await expect(
store.registerPlugin({ manifest, path: "" }),
).rejects.toThrow("Plugin path is required");
});
it("rejects duplicate plugin id", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin1" });
await expect(
store.registerPlugin({ manifest, path: "/path/to/plugin2" }),
).rejects.toThrow("already registered");
});
it("emits plugin:registered event", async () => {
const listener = vi.fn();
store.on("plugin:registered", listener);
const manifest = makeManifest({ id: "event-plugin" });
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
expect(listener).toHaveBeenCalledWith(plugin);
});
});
// ── unregisterPlugin ─────────────────────────────────────────────
describe("unregisterPlugin", () => {
it("removes a registered plugin", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const removed = await store.unregisterPlugin("test-plugin");
expect(removed.id).toBe("test-plugin");
await expect(store.getPlugin("test-plugin")).rejects.toThrow("not found");
});
it("throws on non-existent plugin", async () => {
await expect(store.unregisterPlugin("nonexistent")).rejects.toThrow(
"not found",
);
});
it("emits plugin:unregistered event", async () => {
const listener = vi.fn();
store.on("plugin:unregistered", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.unregisterPlugin("test-plugin");
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].id).toBe("test-plugin");
});
});
// ── getPlugin ────────────────────────────────────────────────────
describe("getPlugin", () => {
it("returns registered plugin", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.getPlugin("test-plugin");
expect(plugin.id).toBe("test-plugin");
expect(plugin.name).toBe("Test Plugin");
});
it("throws ENOENT on non-existent plugin", async () => {
await expect(store.getPlugin("nonexistent")).rejects.toThrow("not found");
});
});
// ── listPlugins ──────────────────────────────────────────────────
describe("listPlugins", () => {
it("returns all registered plugins", async () => {
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-a" }),
path: "/path/a",
});
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-b" }),
path: "/path/b",
});
const plugins = await store.listPlugins();
expect(plugins).toHaveLength(2);
expect(plugins.map((p) => p.id).sort()).toEqual(["plugin-a", "plugin-b"]);
});
it("filters by enabled status", async () => {
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-a" }),
path: "/path/a",
});
const b = await store.registerPlugin({
manifest: makeManifest({ id: "plugin-b" }),
path: "/path/b",
});
await store.disablePlugin("plugin-a");
const enabled = await store.listPlugins({ enabled: true });
expect(enabled).toHaveLength(1);
expect(enabled[0].id).toBe("plugin-b");
const disabled = await store.listPlugins({ enabled: false });
expect(disabled).toHaveLength(1);
expect(disabled[0].id).toBe("plugin-a");
});
it("filters by state", async () => {
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-a" }),
path: "/path/a",
});
await store.registerPlugin({
manifest: makeManifest({ id: "plugin-b" }),
path: "/path/b",
});
// Start plugin-a
await store.updatePluginState("plugin-a", "started");
const installed = await store.listPlugins({ state: "installed" });
expect(installed).toHaveLength(1);
expect(installed[0].id).toBe("plugin-b");
const started = await store.listPlugins({ state: "started" });
expect(started).toHaveLength(1);
expect(started[0].id).toBe("plugin-a");
});
it("returns empty array when no plugins", async () => {
const plugins = await store.listPlugins();
expect(plugins).toEqual([]);
});
});
// ── enablePlugin ─────────────────────────────────────────────────
describe("enablePlugin", () => {
it("sets enabled to true", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.disablePlugin("test-plugin");
const plugin = await store.enablePlugin("test-plugin");
expect(plugin.enabled).toBe(true);
});
it("emits plugin:enabled event", async () => {
const listener = vi.fn();
store.on("plugin:enabled", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.enablePlugin("test-plugin");
expect(listener).toHaveBeenCalledTimes(1);
});
it("emits plugin:updated event", async () => {
const listener = vi.fn();
store.on("plugin:updated", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.enablePlugin("test-plugin");
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── disablePlugin ────────────────────────────────────────────────
describe("disablePlugin", () => {
it("sets enabled to false", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.disablePlugin("test-plugin");
expect(plugin.enabled).toBe(false);
});
it("emits plugin:disabled event", async () => {
const listener = vi.fn();
store.on("plugin:disabled", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.disablePlugin("test-plugin");
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── updatePluginState ────────────────────────────────────────────
describe("updatePluginState", () => {
it("updates state to started", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePluginState("test-plugin", "started");
expect(plugin.state).toBe("started");
});
it("updates state to stopped", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
const plugin = await store.updatePluginState("test-plugin", "stopped");
expect(plugin.state).toBe("stopped");
});
it("updates state to error with message", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePluginState(
"test-plugin",
"error",
"Failed to load",
);
expect(plugin.state).toBe("error");
expect(plugin.error).toBe("Failed to load");
});
it("allows any state to transition to error", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
// installed -> error is valid
const plugin1 = await store.updatePluginState(
"test-plugin",
"error",
"test",
);
expect(plugin1.state).toBe("error");
});
it("rejects invalid state transitions", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
// Cannot go from stopped directly back to installed
await store.updatePluginState("test-plugin", "stopped");
await expect(
store.updatePluginState("test-plugin", "installed"),
).rejects.toThrow("Invalid state transition");
});
it("allows restarting from stopped", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
await store.updatePluginState("test-plugin", "stopped");
const plugin = await store.updatePluginState("test-plugin", "started");
expect(plugin.state).toBe("started");
});
it("emits plugin:stateChanged event", async () => {
const listener = vi.fn();
store.on("plugin:stateChanged", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginState("test-plugin", "started");
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].id).toBe("test-plugin");
expect(listener.mock.calls[0][1]).toBe("installed");
expect(listener.mock.calls[0][2]).toBe("started");
});
});
// ── updatePluginSettings ─────────────────────────────────────────
describe("updatePluginSettings", () => {
it("merges settings", async () => {
const manifest = makeManifest({
settingsSchema: {
apiKey: { type: "string" },
count: { type: "number", defaultValue: 5 },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: { apiKey: "secret123" },
});
const plugin = await store.updatePluginSettings("test-plugin", {
count: 10,
});
expect(plugin.settings).toEqual({ apiKey: "secret123", count: 10 });
});
it("validates required settings", async () => {
const manifest = makeManifest({
settingsSchema: {
apiKey: { type: "string", required: true },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
await expect(
store.updatePluginSettings("test-plugin", {}),
).rejects.toThrow('Setting "apiKey" is required');
});
it("validates setting types", async () => {
const manifest = makeManifest({
settingsSchema: {
count: { type: "number" },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
await expect(
store.updatePluginSettings("test-plugin", { count: "not a number" }),
).rejects.toThrow('Setting "count" must be a number');
});
it("validates enum values", async () => {
const manifest = makeManifest({
settingsSchema: {
color: { type: "enum", enumValues: ["red", "green", "blue"] },
},
});
await store.registerPlugin({
manifest,
path: "/path/to/plugin",
settings: {},
});
await expect(
store.updatePluginSettings("test-plugin", { color: "yellow" }),
).rejects.toThrow('Setting "color" must be one of');
});
it("emits plugin:updated event", async () => {
const listener = vi.fn();
store.on("plugin:updated", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePluginSettings("test-plugin", { key: "value" });
expect(listener).toHaveBeenCalledTimes(1);
});
});
// ── updatePlugin ─────────────────────────────────────────────────
describe("updatePlugin", () => {
it("updates name", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", { name: "New Name" });
expect(plugin.name).toBe("New Name");
});
it("updates version", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", { version: "2.0.0" });
expect(plugin.version).toBe("2.0.0");
});
it("updates description", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", {
description: "New description",
});
expect(plugin.description).toBe("New description");
});
it("updates path", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", {
path: "/new/path/to/plugin",
});
expect(plugin.path).toBe("/new/path/to/plugin");
});
it("updates dependencies", async () => {
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
const plugin = await store.updatePlugin("test-plugin", {
dependencies: ["dep-a", "dep-b"],
});
expect(plugin.dependencies).toEqual(["dep-a", "dep-b"]);
});
it("emits plugin:updated event", async () => {
const listener = vi.fn();
store.on("plugin:updated", listener);
const manifest = makeManifest();
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
await store.updatePlugin("test-plugin", { name: "Updated" });
expect(listener).toHaveBeenCalledTimes(1);
});
});
});