mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
Two shards on release/v3.8.51 went red in one day with the same signature —
"ENOTEMPTY, Directory not empty: /tmp/omniroute-<test>-XXXXXX" — from
combo-same-provider-cascade (Unit Tests fast-path 4/4, on a PR that touches only
.github/) and auth-policy-embeddings-webfetch-7785 (the 20k-test TIA step). Both pass
alone and on re-run: the cleanup races something still writing into the directory
(SQLite WAL/-shm checkpoint, a worker, the backup) and under a loaded hosted runner
the window opens. 1154 test files do their own cleanup with
fs.rmSync(dir, { recursive: true, force: true }); 57 already asked for retries.
One-shot codemod (scripts/ad-hoc/codemod-rm-maxretries.mjs, kept for the record):
every rm / rmSync / rmdirSync option object with `recursive: true` and no
`maxRetries` gains `maxRetries: 5, retryDelay: 100` — Node itself then retries
ENOTEMPTY/EBUSY/EPERM for up to ~0.5 s before giving up. 2243 call sites in 1292
files under tests/, the shared tests/_setup/isolateDataDir.ts exit hook included.
Only the option object changes: no call site, assertion or import is touched.
Validation: prettier and ESLint (with the frozen suppressions) clean on all 1292
files; a random 20-file sample runs green (quota-redis-store hangs identically on
the untouched tree — it needs a Redis on localhost, an environment matter). The
four unit shards on this PR are the full run.
404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
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-name>/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, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
|
|
// Track temp source dirs for cleanup
|
|
const activeSourceDirs: string[] = [];
|
|
|
|
function cleanupSourceDirs() {
|
|
for (const dir of activeSourceDirs) {
|
|
try {
|
|
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
} 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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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");
|
|
});
|