mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 19:02:17 +03:00
`main` has been red since b342c1a361 on the vitest and integration gates:
✖ tests/unit/autoCombo/provider-family-combos.test.ts > auto/<family>
✖ chat pipeline applies Codex OAuth fingerprint and priority tier inside combos
Both call resetStorage() from beforeEach, which does an fs.rmSync(TEST_DATA_DIR,
{recursive: true, force: true}) with no retry, and intermittently loses the race
with a not-yet-released SQLite handle (ENOTEMPTY).
release/v3.8.51 fixed this in #11968 with a mechanical codemod adding
maxRetries/retryDelay to every recursive rm/rmSync/rmdirSync under tests/, but
that PR landed only on the release branch. Because main only receives work at
the release squash, it stayed broken for the whole cycle — and repo-wide gates
then turn every open PR into main red on checks unrelated to their diff.
This is the --base main twin: re-runs the same codemod that already shipped on
the release branch (scripts/ad-hoc/codemod-rm-maxretries.mjs), so the two
branches converge on identical test-teardown semantics. Test-only; no product
logic is touched.
The remaining three failures reported on #12133 (unit full suite exceeding its
4800s ceiling, package-artifact exceeding 1200s, and the boot-smoke that is
skipped as a consequence) are runner-contention timeouts, not code defects —
validate-release-green.mjs runs those heavy gates concurrently on one shared
hosted runner. There is no fix to port for those.
380 lines
14 KiB
TypeScript
380 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");
|
|
});
|