mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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:
31
src/lib/db/migrations/076_create_plugins.sql
Normal file
31
src/lib/db/migrations/076_create_plugins.sql
Normal 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);
|
||||
78
tests/unit/plugins-loader.test.ts
Normal file
78
tests/unit/plugins-loader.test.ts
Normal 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");
|
||||
});
|
||||
51
tests/unit/plugins-manager.test.ts
Normal file
51
tests/unit/plugins-manager.test.ts
Normal 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"));
|
||||
});
|
||||
107
tests/unit/plugins-scanner.test.ts
Normal file
107
tests/unit/plugins-scanner.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user