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.
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"
|
|
);
|
|
}
|
|
);
|