mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +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.
264 lines
8.8 KiB
TypeScript
264 lines
8.8 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { readdirSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { loadPlugin, 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: unknown) => 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");
|
|
});
|
|
|
|
test(
|
|
"loadPlugin runs hooks in an isolated child process over IPC",
|
|
{ timeout: 5_000 },
|
|
async (t) => {
|
|
const pluginDir = await mkdtemp(join(tmpdir(), "omniroute-plugin-loader-"));
|
|
const entryPoint = join(pluginDir, "index.mjs");
|
|
let loaded: LoadedPlugin | undefined;
|
|
|
|
t.after(async () => {
|
|
loaded?.cleanup();
|
|
await rm(pluginDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
});
|
|
|
|
await writeFile(
|
|
entryPoint,
|
|
`
|
|
export async function onRequest(ctx) {
|
|
return {
|
|
body: { ...ctx.body, touchedByPlugin: true },
|
|
metadata: { pluginHook: "onRequest" },
|
|
};
|
|
}
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
loaded = await loadPlugin(entryPoint, {
|
|
name: "ipc-test",
|
|
version: "1.0.0",
|
|
license: "MIT",
|
|
main: "index.mjs",
|
|
source: "local",
|
|
tags: [],
|
|
requires: { permissions: [] },
|
|
hooks: { onRequest: true, onResponse: false, onError: false },
|
|
skills: [],
|
|
enabledByDefault: false,
|
|
configSchema: {},
|
|
});
|
|
|
|
const result = await loaded.plugin.onRequest?.({
|
|
requestId: "test-request",
|
|
body: { model: "gpt-4" },
|
|
model: "gpt-4",
|
|
metadata: {},
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
body: { model: "gpt-4", touchedByPlugin: true },
|
|
metadata: { pluginHook: "onRequest" },
|
|
});
|
|
}
|
|
);
|
|
|
|
// Regression (PR #3562, Hard Rule #18): the loader must build the plugin's
|
|
// lifecycle-hook methods (onInstall/onActivate/onDeactivate/onUninstall) when —
|
|
// and only when — the manifest declares them. manager.ts registers exactly these
|
|
// methods with emitHook; before #3562 the loader only wired onRequest/onResponse/
|
|
// onError, so the lifecycle hooks were declared-but-dead (manager registered
|
|
// `undefined` and the fire-and-forget emitHook never reached the plugin).
|
|
test(
|
|
"loadPlugin wires declared lifecycle hooks and skips undeclared ones",
|
|
{ timeout: 5_000 },
|
|
async (t) => {
|
|
const pluginDir = await mkdtemp(join(tmpdir(), "omniroute-plugin-lifecycle-"));
|
|
const entryPoint = join(pluginDir, "index.mjs");
|
|
let loaded: LoadedPlugin | undefined;
|
|
|
|
t.after(async () => {
|
|
loaded?.cleanup();
|
|
await rm(pluginDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
});
|
|
|
|
await writeFile(
|
|
entryPoint,
|
|
`
|
|
export async function onInstall(_payload) {}
|
|
export async function onActivate(_payload) {}
|
|
export async function onDeactivate(_payload) {}
|
|
export async function onUninstall(_payload) {}
|
|
`,
|
|
"utf-8"
|
|
);
|
|
|
|
loaded = await loadPlugin(entryPoint, {
|
|
name: "lifecycle-test",
|
|
version: "1.0.0",
|
|
license: "MIT",
|
|
main: "index.mjs",
|
|
source: "local",
|
|
tags: [],
|
|
requires: { permissions: [] },
|
|
hooks: {
|
|
onRequest: false,
|
|
onResponse: false,
|
|
onError: false,
|
|
onInstall: true,
|
|
onActivate: true,
|
|
onDeactivate: false, // declared in code but disabled in manifest — must NOT be wired
|
|
onUninstall: true,
|
|
},
|
|
skills: [],
|
|
enabledByDefault: false,
|
|
configSchema: {},
|
|
});
|
|
|
|
// Hooks enabled in the manifest must be wired as callable methods — this is
|
|
// exactly what manager.ts hands to registerHook(hookName, name, handler).
|
|
assert.equal(typeof loaded.plugin.onInstall, "function", "onInstall must be wired");
|
|
assert.equal(typeof loaded.plugin.onActivate, "function", "onActivate must be wired");
|
|
assert.equal(typeof loaded.plugin.onUninstall, "function", "onUninstall must be wired");
|
|
|
|
// A hook disabled in the manifest must NOT be wired, even if the plugin
|
|
// exports it (gated by the manifest flag).
|
|
assert.equal(loaded.plugin.onDeactivate, undefined, "disabled onDeactivate must not be wired");
|
|
|
|
// The wired method must bridge to the worker without throwing (fire-and-forget).
|
|
await loaded.plugin.onActivate?.({ name: "lifecycle-test", version: "1.0.0" });
|
|
}
|
|
);
|
|
|
|
// Regression (Hard Rule #18): the generated host script used to be deleted with a
|
|
// fire-and-forget `rm(...).catch()`. That unlink loses the race against process exit —
|
|
// under `npm run test:unit` (--test-force-exit) the runner tore the process down before
|
|
// the promise settled, so every plugin load leaked one omniroute-plugin-host-*.mjs into
|
|
// TMPDIR and they accumulated run over run. cleanup() must have removed it by return.
|
|
test(
|
|
"cleanup() removes the generated host script before returning",
|
|
{ timeout: 5_000 },
|
|
async (t) => {
|
|
const pluginDir = await mkdtemp(join(tmpdir(), "omniroute-plugin-loader-"));
|
|
const entryPoint = join(pluginDir, "index.mjs");
|
|
// Redirect the loader's tmpdir() so a concurrent test file's host scripts cannot
|
|
// land in the directory we count (test:unit runs at --test-concurrency=20).
|
|
// POSIX reads TMPDIR, Windows reads TEMP/TMP — set all three.
|
|
const hostScriptDir = await mkdtemp(join(tmpdir(), "omniroute-hostscript-"));
|
|
const tmpEnvKeys = ["TMPDIR", "TEMP", "TMP"] as const;
|
|
const savedEnv = tmpEnvKeys.map((k) => [k, process.env[k]] as const);
|
|
let loaded: LoadedPlugin | undefined;
|
|
|
|
t.after(async () => {
|
|
loaded?.cleanup();
|
|
for (const [key, value] of savedEnv) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
await rm(pluginDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
await rm(hostScriptDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
});
|
|
|
|
await writeFile(entryPoint, "export async function onRequest() { return {}; }\n", "utf-8");
|
|
for (const key of tmpEnvKeys) process.env[key] = hostScriptDir;
|
|
|
|
loaded = await loadPlugin(entryPoint, {
|
|
name: "host-script-cleanup-test",
|
|
version: "1.0.0",
|
|
license: "MIT",
|
|
main: "index.mjs",
|
|
source: "local",
|
|
tags: [],
|
|
requires: { permissions: [] },
|
|
hooks: { onRequest: true, onResponse: false, onError: false },
|
|
skills: [],
|
|
enabledByDefault: false,
|
|
configSchema: {},
|
|
});
|
|
|
|
assert.deepEqual(
|
|
readdirSync(hostScriptDir).filter((f) => f.startsWith("omniroute-plugin-host-")).length,
|
|
1,
|
|
"sanity: loadPlugin writes exactly one host script"
|
|
);
|
|
|
|
loaded.cleanup();
|
|
loaded = undefined;
|
|
|
|
assert.deepEqual(
|
|
readdirSync(hostScriptDir).filter((f) => f.startsWith("omniroute-plugin-host-")),
|
|
[],
|
|
"cleanup() must delete the host script synchronously, not on a later tick"
|
|
);
|
|
}
|
|
);
|