test(plugins): add scanner, loader, manager unit tests

- scanner: 9 tests (discovery, hidden dirs, validation, entry point, multiple)
- loader: 5 tests (type contracts, Plugin/PluginContext/PluginResult interfaces)
- manager: 6 tests (singleton, lifecycle methods, error on unknown)
- Total: 20 tests, all passing
This commit is contained in:
oyi77
2026-05-29 23:29:35 +07:00
parent 20b2081099
commit 478350ccfc
4 changed files with 267 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
-- 059: Plugin system tables
-- WordPress-style plugin management with lifecycle tracking
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
version TEXT NOT NULL DEFAULT '1.0.0',
description TEXT,
author TEXT,
license TEXT DEFAULT 'MIT',
main TEXT NOT NULL DEFAULT 'index.js',
source TEXT NOT NULL DEFAULT 'local',
tags TEXT DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'installed'
CHECK (status IN ('installed', 'active', 'inactive', 'error')),
enabled INTEGER NOT NULL DEFAULT 0,
manifest TEXT NOT NULL,
config TEXT DEFAULT '{}',
config_schema TEXT DEFAULT '{}',
hooks TEXT DEFAULT '[]',
permissions TEXT DEFAULT '[]',
plugin_dir TEXT NOT NULL,
error_message TEXT,
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
activated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins(status);
CREATE INDEX IF NOT EXISTS idx_plugins_enabled ON plugins(enabled);
CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name);

View File

@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
// Loader uses child_process.fork() — we test the module exports and types,
// not actual fork behavior (that requires integration tests with real plugins).
import type { LoadedPlugin } from "../../src/lib/plugins/loader.ts";
import type { Plugin, PluginContext, PluginResult } from "../../src/lib/plugins/index.ts";
// ── Type checks ──
test("LoadedPlugin interface has required fields", () => {
// Verify the type structure exists by checking the module exports
const mock: LoadedPlugin = {
name: "test",
manifest: {
name: "test",
version: "1.0.0",
license: "MIT",
main: "index.js",
source: "local",
tags: [],
requires: { permissions: [] },
hooks: { onRequest: false, onResponse: false, onError: false },
skills: [],
enabledByDefault: false,
configSchema: {},
},
plugin: { name: "test" },
cleanup: () => {},
};
assert.equal(mock.name, "test");
assert.equal(typeof mock.cleanup, "function");
});
test("Plugin interface supports lifecycle hooks", () => {
const plugin: Plugin = {
name: "test",
onRequest: async (_ctx: PluginContext): Promise<PluginResult | void> => {
return { blocked: false };
},
onResponse: async (_ctx: PluginContext, response: any) => response,
onError: async (_ctx: PluginContext, _error: Error) => null,
};
assert.equal(typeof plugin.onRequest, "function");
assert.equal(typeof plugin.onResponse, "function");
assert.equal(typeof plugin.onError, "function");
});
test("PluginContext has required fields", () => {
const ctx: PluginContext = {
requestId: "test-123",
body: { model: "gpt-4" },
model: "gpt-4",
provider: "openai",
metadata: {},
};
assert.equal(ctx.requestId, "test-123");
assert.equal(ctx.model, "gpt-4");
});
test("PluginResult supports blocking", () => {
const blocked: PluginResult = {
blocked: true,
response: { error: "denied" },
};
assert.ok(blocked.blocked);
assert.deepEqual(blocked.response, { error: "denied" });
});
test("PluginResult supports body modification", () => {
const modified: PluginResult = {
body: { model: "gpt-4-turbo" },
metadata: { plugin: "model-switcher" },
};
assert.equal(modified.body.model, "gpt-4-turbo");
assert.equal(modified.metadata?.plugin, "model-switcher");
});

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
// Manager is a singleton that depends on DB, scanner, and loader.
// We test the module structure and type contracts here.
// Full lifecycle tests require integration setup with SQLite.
import { pluginManager } from "../../src/lib/plugins/manager.ts";
// ── Singleton ──
test("pluginManager is a singleton", () => {
const a = pluginManager;
const b = pluginManager;
assert.strictEqual(a, b);
});
test("pluginManager has all lifecycle methods", () => {
assert.equal(typeof pluginManager.install, "function");
assert.equal(typeof pluginManager.activate, "function");
assert.equal(typeof pluginManager.deactivate, "function");
assert.equal(typeof pluginManager.uninstall, "function");
assert.equal(typeof pluginManager.scan, "function");
assert.equal(typeof pluginManager.loadAll, "function");
assert.equal(typeof pluginManager.getLoaded, "function");
assert.equal(typeof pluginManager.listAll, "function");
assert.equal(typeof pluginManager.getPlugin, "function");
});
test("pluginManager.getLoaded returns undefined for unknown plugin", () => {
const result = pluginManager.getLoaded("nonexistent-plugin");
assert.equal(result, undefined);
});
test("pluginManager.install throws for invalid directory", async () => {
await assert.rejects(
() => pluginManager.install("/nonexistent/path"),
(err: Error) => {
assert.ok(err.message.includes("No valid plugin found"));
return true;
}
);
});
test("pluginManager.activate throws for unknown plugin", async () => {
await assert.rejects(() => pluginManager.activate("nonexistent-plugin"));
});
test("pluginManager.uninstall throws for unknown plugin", async () => {
await assert.rejects(() => pluginManager.uninstall("nonexistent-plugin"));
});

View File

@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, mkdir, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { scanPluginDir, getDefaultPluginDir } from "../../src/lib/plugins/scanner.ts";
let tmpDir: string;
test.beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "plugin-scan-test-"));
});
test.afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── getDefaultPluginDir ──
test("getDefaultPluginDir returns ~/.omniroute/plugins", () => {
const dir = getDefaultPluginDir();
assert.ok(dir.endsWith(".omniroute/plugins"));
});
// ── scanPluginDir ──
test("returns empty for non-existent directory", async () => {
const result = await scanPluginDir("/nonexistent/path");
assert.deepEqual(result.plugins, []);
assert.deepEqual(result.errors, []);
});
test("returns empty for empty directory", async () => {
const result = await scanPluginDir(tmpDir);
assert.deepEqual(result.plugins, []);
assert.deepEqual(result.errors, []);
});
test("skips hidden directories", async () => {
await mkdir(join(tmpDir, ".hidden"));
await writeFile(
join(tmpDir, ".hidden", "plugin.json"),
JSON.stringify({ name: "hidden", version: "1.0.0" })
);
const result = await scanPluginDir(tmpDir);
assert.equal(result.plugins.length, 0);
});
test("discovers valid plugin", async () => {
const pluginDir = join(tmpDir, "my-plugin");
await mkdir(pluginDir);
await writeFile(
join(pluginDir, "plugin.json"),
JSON.stringify({ name: "my-plugin", version: "1.0.0" })
);
await writeFile(join(pluginDir, "index.js"), "module.exports = {};");
const result = await scanPluginDir(tmpDir);
assert.equal(result.plugins.length, 1);
assert.equal(result.plugins[0].name, "my-plugin");
assert.equal(result.plugins[0].manifest.version, "1.0.0");
});
test("reports error for missing plugin.json", async () => {
await mkdir(join(tmpDir, "no-manifest"));
const result = await scanPluginDir(tmpDir);
assert.equal(result.plugins.length, 0);
assert.equal(result.errors.length, 1);
assert.ok(result.errors[0].error.includes("no plugin.json"));
});
test("reports error for invalid manifest", async () => {
const pluginDir = join(tmpDir, "bad-manifest");
await mkdir(pluginDir);
await writeFile(
join(pluginDir, "plugin.json"),
JSON.stringify({ name: "BAD NAME!", version: "nope" })
);
const result = await scanPluginDir(tmpDir);
assert.equal(result.plugins.length, 0);
assert.equal(result.errors.length, 1);
assert.ok(result.errors[0].error.includes("invalid manifest"));
});
test("reports error for missing entry point", async () => {
const pluginDir = join(tmpDir, "no-entry");
await mkdir(pluginDir);
await writeFile(
join(pluginDir, "plugin.json"),
JSON.stringify({ name: "no-entry", version: "1.0.0", main: "missing.js" })
);
const result = await scanPluginDir(tmpDir);
assert.equal(result.plugins.length, 0);
assert.equal(result.errors.length, 1);
assert.ok(result.errors[0].error.includes("entry point not found"));
});
test("discovers multiple plugins", async () => {
for (const name of ["plugin-a", "plugin-b"]) {
const d = join(tmpDir, name);
await mkdir(d);
await writeFile(join(d, "plugin.json"), JSON.stringify({ name, version: "1.0.0" }));
await writeFile(join(d, "index.js"), "module.exports = {};");
}
const result = await scanPluginDir(tmpDir);
assert.equal(result.plugins.length, 2);
});