import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; // ── Temp dirs ── const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-lifecycle-")); process.env.DATA_DIR = TEST_DATA_DIR; // ── Dynamic imports (after DATA_DIR set) ── const core = await import("../../src/lib/db/core.ts"); const dbPlugins = await import("../../src/lib/db/plugins.ts"); const hooks = await import("../../src/lib/plugins/hooks.ts"); const { pluginManager } = await import("../../src/lib/plugins/manager.ts"); // ── Fixture: create a valid plugin in a temp directory ── // Scanner expects: sourceDir//plugin.json + index.js // Returns the sourceDir (parent) to pass to pluginManager.install() function writeTestPlugin(opts?: { name?: string; onRequest?: boolean; enabledByDefault?: boolean }) { const name = opts?.name ?? "test-lifecycle-plugin"; const onRequest = opts?.onRequest ?? true; const enabledByDefault = opts?.enabledByDefault ?? false; // Create a fresh source dir for this plugin (scanner scans for subdirs) const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugin-src-")); const pluginDir = path.join(sourceDir, name); fs.mkdirSync(pluginDir, { recursive: true }); const manifest = { name, version: "1.0.0", description: "Integration test plugin", author: "test", main: "index.js", hooks: { onRequest, onResponse: false, onError: false }, enabledByDefault, requires: { permissions: [] }, }; fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify(manifest, null, 2)); // Plugin exports an onRequest handler that returns metadata (child-process isolation means // the handler cannot mutate the parent's ctx object directly — it must return the result). const indexJs = onRequest ? `module.exports.onRequest = function(ctx) { return { metadata: { hookCalled: true } }; };` : `module.exports = {};`; fs.writeFileSync(path.join(pluginDir, "index.js"), indexJs); return { sourceDir, pluginDir, name }; } // ── Helpers ── function cleanupDir(dir: string) { fs.rmSync(dir, { recursive: true, force: true }); } // Track temp source dirs for cleanup const activeSourceDirs: string[] = []; function cleanupSourceDirs() { for (const dir of activeSourceDirs) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} } activeSourceDirs.length = 0; } // ── Lifecycle ── test.beforeEach(() => { core.resetDbInstance(); hooks.resetHooks(); cleanupDir(TEST_DATA_DIR); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); cleanupSourceDirs(); }); test.after(() => { core.resetDbInstance(); cleanupSourceDirs(); try { cleanupDir(TEST_DATA_DIR); } catch {} }); // ── Tests: Install ── test("install: copies plugin and creates DB row", async () => { const { sourceDir, name } = writeTestPlugin({ name: "install-test" }); activeSourceDirs.push(sourceDir); const row = await pluginManager.install(sourceDir); assert.equal(row.name, name); assert.equal(row.version, "1.0.0"); assert.equal(row.description, "Integration test plugin"); assert.equal(row.status, "installed"); // Verify DB lookup works const fromDb = dbPlugins.getPluginByName(name); assert.ok(fromDb, "plugin should be retrievable from DB"); assert.equal(fromDb!.name, name); assert.equal(fromDb!.version, "1.0.0"); await pluginManager.uninstall(name); }); test("install: throws on duplicate install", async () => { const { sourceDir, name } = writeTestPlugin({ name: "dup-test" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await assert.rejects(() => pluginManager.install(sourceDir), /already installed/); await pluginManager.uninstall(name); }); test("install: throws on invalid source directory", async () => { const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-empty-")); activeSourceDirs.push(emptyDir); await assert.rejects(() => pluginManager.install(emptyDir), /No valid plugin found/); }); // ── Tests: Activate ── test("activate: transitions DB status to active", async () => { const { sourceDir, name } = writeTestPlugin({ name: "activate-status" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await pluginManager.activate(name); const row = dbPlugins.getPluginByName(name); assert.ok(row, "plugin should exist in DB"); assert.equal(row!.status, "active"); assert.equal(row!.enabled, 1); await pluginManager.uninstall(name); }); test("activate: registers manifest-declared hooks", async () => { const { sourceDir, name } = writeTestPlugin({ name: "activate-hooks" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await pluginManager.activate(name); // onRequest should be registered (manifest declares it) const registered = hooks.getHooks("onRequest"); const found = registered.find((r) => r.pluginName === name); assert.ok(found, "onRequest hook should be registered for the plugin"); // onResponse should NOT be registered (manifest says false) const responseHooks = hooks.getHooks("onResponse"); const notFound = responseHooks.find((r) => r.pluginName === name); assert.equal(notFound, undefined, "onResponse hook should not be registered"); await pluginManager.uninstall(name); }); test("activate: plugin handler fires on hook emit", async () => { const { sourceDir, name } = writeTestPlugin({ name: "activate-emit" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await pluginManager.activate(name); // Fire the onRequest hook with a PluginContext-like payload. // Plugins run in isolated child processes and cannot mutate the parent's object // in-place — use emitHookBlocking and inspect the returned merged metadata. const payload = { requestId: "test-req", body: {}, model: "test", metadata: {} }; const result = await hooks.emitHookBlocking("onRequest", payload); // The handler sets metadata.hookCalled = true; it is returned in the merged result. assert.deepEqual((result as Record).metadata, { hookCalled: true }); await pluginManager.uninstall(name); }); test("activate: is idempotent for already-active plugin", async () => { const { sourceDir, name } = writeTestPlugin({ name: "activate-idempotent" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await pluginManager.activate(name); // Second activate should not throw await pluginManager.activate(name); const row = dbPlugins.getPluginByName(name); assert.equal(row!.status, "active"); await pluginManager.uninstall(name); }); test("activate: throws for nonexistent plugin", async () => { await assert.rejects(() => pluginManager.activate("no-such-plugin"), /not found/); }); // ── Tests: Deactivate ── test("deactivate: transitions DB status to inactive", async () => { const { sourceDir, name } = writeTestPlugin({ name: "deactivate-status" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await pluginManager.activate(name); await pluginManager.deactivate(name); const row = dbPlugins.getPluginByName(name); assert.ok(row, "plugin should still exist in DB after deactivation"); assert.equal(row!.status, "inactive"); await pluginManager.uninstall(name); }); test("deactivate: unregisters all hooks for the plugin", async () => { const { sourceDir, name } = writeTestPlugin({ name: "deactivate-hooks" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await pluginManager.activate(name); // Verify hook is registered before deactivation assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name)); await pluginManager.deactivate(name); // Hook should be gone const after = hooks.getHooks("onRequest"); assert.equal(after.find((r) => r.pluginName === name), undefined, "hook should be unregistered"); await pluginManager.uninstall(name); }); test("deactivate: hook no longer fires after deactivation", async () => { const { sourceDir, name } = writeTestPlugin({ name: "deactivate-nofire" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await pluginManager.activate(name); // Verify hook fires while active (use emitHookBlocking — child-process isolation // means plugins cannot mutate the parent's in-memory payload object in-place). const payload = { requestId: "req-1", body: {}, model: "test", metadata: {} }; const result1 = await hooks.emitHookBlocking("onRequest", payload); assert.deepEqual((result1 as Record).metadata, { hookCalled: true }); await pluginManager.deactivate(name); // After deactivation, hook is unregistered — emitHookBlocking returns empty metadata. const payload2 = { requestId: "req-2", body: {}, model: "test", metadata: {} }; const result2 = await hooks.emitHookBlocking("onRequest", payload2); assert.deepEqual((result2 as Record).metadata, {}); await pluginManager.uninstall(name); }); // ── Tests: Uninstall ── test("uninstall: removes DB row", async () => { const { sourceDir, name } = writeTestPlugin({ name: "uninstall-db" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); assert.ok(dbPlugins.getPluginByName(name), "should exist before uninstall"); await pluginManager.uninstall(name); assert.equal(dbPlugins.getPluginByName(name), null, "should be removed from DB"); }); test("uninstall: removes plugin directory from disk", async () => { const { sourceDir, name } = writeTestPlugin({ name: "uninstall-dir" }); activeSourceDirs.push(sourceDir); const row = await pluginManager.install(sourceDir); const installedDir = row.pluginDir; assert.ok(fs.existsSync(installedDir), "plugin dir should exist after install"); await pluginManager.uninstall(name); assert.ok(!fs.existsSync(installedDir), "plugin dir should be removed after uninstall"); }); test("uninstall: deactivates before removing if active", async () => { const { sourceDir, name } = writeTestPlugin({ name: "uninstall-active" }); activeSourceDirs.push(sourceDir); await pluginManager.install(sourceDir); await pluginManager.activate(name); // Verify active + hook registered assert.equal(dbPlugins.getPluginByName(name)!.status, "active"); assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name)); await pluginManager.uninstall(name); // Plugin should be fully gone assert.equal(dbPlugins.getPluginByName(name), null); assert.equal(hooks.getHooks("onRequest").find((r) => r.pluginName === name), undefined); }); test("uninstall: throws for nonexistent plugin", async () => { await assert.rejects(() => pluginManager.uninstall("ghost-plugin"), /not found/); }); // ── Tests: Full lifecycle ── test("full lifecycle: install -> activate -> hook fires -> deactivate -> uninstall", async () => { const { sourceDir, name } = writeTestPlugin({ name: "full-lifecycle" }); activeSourceDirs.push(sourceDir); // 1. Install const row = await pluginManager.install(sourceDir); assert.equal(row.status, "installed"); assert.ok(dbPlugins.getPluginByName(name), "exists in DB after install"); // 2. Activate await pluginManager.activate(name); const afterActivate = dbPlugins.getPluginByName(name); assert.equal(afterActivate!.status, "active"); assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name), "hook registered"); // 3. Fire hook (use emitHookBlocking — child-process isolation means plugins cannot // mutate the parent's in-memory payload object; check the returned merged result). const payload = { requestId: "lifecycle-req", body: {}, model: "test", metadata: {} }; const hookResult = await hooks.emitHookBlocking("onRequest", payload); assert.deepEqual( (hookResult as Record).metadata, { hookCalled: true }, "hook handler executed" ); // 4. Deactivate await pluginManager.deactivate(name); const afterDeactivate = dbPlugins.getPluginByName(name); assert.equal(afterDeactivate!.status, "inactive"); assert.equal( hooks.getHooks("onRequest").find((r) => r.pluginName === name), undefined, "hook unregistered after deactivation" ); // 5. Uninstall await pluginManager.uninstall(name); assert.equal(dbPlugins.getPluginByName(name), null, "removed from DB"); }); // ── Tests: Multi-plugin isolation ── test("multiple plugins: hooks are isolated per plugin", async () => { const p1 = writeTestPlugin({ name: "multi-p1" }); const p2 = writeTestPlugin({ name: "multi-p2" }); activeSourceDirs.push(p1.sourceDir, p2.sourceDir); await pluginManager.install(p1.sourceDir); await pluginManager.install(p2.sourceDir); await pluginManager.activate("multi-p1"); await pluginManager.activate("multi-p2"); // Both should have onRequest hooks const onRequest = hooks.getHooks("onRequest"); assert.ok(onRequest.find((r) => r.pluginName === "multi-p1")); assert.ok(onRequest.find((r) => r.pluginName === "multi-p2")); // Deactivate only p1 await pluginManager.deactivate("multi-p1"); const afterDeactivate = hooks.getHooks("onRequest"); assert.equal(afterDeactivate.find((r) => r.pluginName === "multi-p1"), undefined); assert.ok(afterDeactivate.find((r) => r.pluginName === "multi-p2"), "p2 hook still registered"); // Cleanup await pluginManager.uninstall("multi-p1"); await pluginManager.uninstall("multi-p2"); });