Files
openclaw/src/plugins/setup-registry.test.ts
Vincent Koc 8ba50aa23e refactor(plugins): prefer setup descriptors for setup lookup (#64786)
* refactor(plugins): prefer setup descriptors for setup lookup

* fix(plugins): harden setup descriptor lookup

* fix(plugins): keep sync cli backend setup results

* fix(plugins): resolve setup registry rebase

* fix(plugins): preserve fail-closed cli backend lookup

* fix(plugins): fail closed on shadowed setup owners

* fix(plugins): swallow async setup register rejections
2026-04-12 03:29:24 +01:00

663 lines
21 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
import {
getRegistryJitiMocks,
resetRegistryJitiMocks,
} from "./test-helpers/registry-jiti-mocks.js";
const tempDirs: string[] = [];
const mocks = getRegistryJitiMocks();
let clearPluginSetupRegistryCache: typeof import("./setup-registry.js").clearPluginSetupRegistryCache;
let setupRegistryTesting: typeof import("./setup-registry.js").__testing;
let resolvePluginSetupRegistry: typeof import("./setup-registry.js").resolvePluginSetupRegistry;
let resolvePluginSetupProvider: typeof import("./setup-registry.js").resolvePluginSetupProvider;
let resolvePluginSetupCliBackend: typeof import("./setup-registry.js").resolvePluginSetupCliBackend;
let runPluginSetupConfigMigrations: typeof import("./setup-registry.js").runPluginSetupConfigMigrations;
function makeTempDir(): string {
return makeTrackedTempDir("openclaw-setup-registry", tempDirs);
}
async function expectNoUnhandledRejection(run: () => void | Promise<void>): Promise<void> {
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
await run();
await Promise.resolve();
await Promise.resolve();
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
expect(unhandledRejections).toEqual([]);
}
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("setup-registry getJiti", () => {
beforeEach(async () => {
resetRegistryJitiMocks();
vi.resetModules();
({
__testing: setupRegistryTesting,
clearPluginSetupRegistryCache,
resolvePluginSetupRegistry,
resolvePluginSetupProvider,
resolvePluginSetupCliBackend,
runPluginSetupConfigMigrations,
} = await import("./setup-registry.js"));
clearPluginSetupRegistryCache();
});
it("disables native jiti loading on Windows for setup-api modules", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "test-plugin", rootDir: pluginRoot }],
diagnostics: [],
});
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
resolvePluginSetupRegistry({
workspaceDir: pluginRoot,
env: {},
});
} finally {
platformSpy.mockRestore();
}
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "setup-api.js"));
expect(mocks.createJiti.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
tryNative: false,
}),
);
});
it("skips setup-api loading when config has no relevant migration triggers", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "amazon-bedrock",
rootDir: pluginRoot,
configContracts: {
compatibilityMigrationPaths: ["models.bedrockDiscovery"],
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerConfigMigration: (migrate: (config: unknown) => unknown) => void;
}) {
api.registerConfigMigration((config) => ({ config, changes: ["unexpected"] }));
},
},
});
});
const result = runPluginSetupConfigMigrations({
config: {
models: {
providers: {
openai: { baseUrl: "https://api.openai.com/v1" },
},
},
} as never,
env: {},
});
expect(result.changes).toEqual([]);
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("loads only plugins whose manifest migration triggers match the config", () => {
const bedrockRoot = makeTempDir();
const voiceCallRoot = makeTempDir();
fs.writeFileSync(path.join(bedrockRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(voiceCallRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "amazon-bedrock",
rootDir: bedrockRoot,
configContracts: {
compatibilityMigrationPaths: ["models.bedrockDiscovery"],
},
},
{
id: "voice-call",
rootDir: voiceCallRoot,
configContracts: {
compatibilityMigrationPaths: ["plugins.entries.voice-call.config"],
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation((modulePath: string) => {
const pluginId = modulePath.includes(bedrockRoot) ? "amazon-bedrock" : "voice-call";
return () => ({
default: {
register(api: {
registerConfigMigration: (migrate: (config: unknown) => unknown) => void;
}) {
api.registerConfigMigration((config) => ({
config,
changes: [pluginId],
}));
},
},
});
});
const result = runPluginSetupConfigMigrations({
config: {
models: {
bedrockDiscovery: {
enabled: true,
},
},
} as never,
env: {},
});
expect(result.changes).toEqual(["amazon-bedrock"]);
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(bedrockRoot, "setup-api.js"));
});
it("still loads explicitly configured plugin entries without manifest trigger metadata", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "voice-call", rootDir: pluginRoot }],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerConfigMigration: (migrate: (config: unknown) => unknown) => void;
}) {
api.registerConfigMigration((config) => ({ config, changes: ["voice-call"] }));
},
},
});
});
const result = runPluginSetupConfigMigrations({
config: {
plugins: {
entries: {
"voice-call": {
config: {
provider: "log",
},
},
},
},
} as never,
env: {},
});
expect(result.changes).toEqual(["voice-call"]);
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
});
it("prefers setup provider descriptors over top-level provider ids", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "amazon-bedrock",
rootDir: pluginRoot,
providers: ["legacy-bedrock"],
setup: {
providers: [{ id: "amazon-bedrock" }],
requiresRuntime: true,
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerProvider: (provider: { id: string; label: string; auth: [] }) => void;
}) {
api.registerProvider({
id: "amazon-bedrock",
label: "Amazon Bedrock",
auth: [],
});
},
},
});
});
expect(resolvePluginSetupProvider({ provider: "amazon-bedrock", env: {} })).toEqual(
expect.objectContaining({
id: "amazon-bedrock",
label: "Amazon Bedrock",
}),
);
expect(resolvePluginSetupProvider({ provider: "legacy-bedrock", env: {} })).toBeUndefined();
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "setup-api.js"));
});
it("resolves setup cli backends from descriptors without loading every setup-api", () => {
const openaiRoot = makeTempDir();
const anthropicRoot = makeTempDir();
fs.writeFileSync(path.join(openaiRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(anthropicRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: openaiRoot,
cliBackends: ["legacy-openai-cli"],
setup: {
cliBackends: ["codex-cli"],
requiresRuntime: true,
},
},
{
id: "anthropic",
rootDir: anthropicRoot,
cliBackends: ["claude-cli"],
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation((modulePath: string) => {
return () => ({
default: {
register(api: {
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerCliBackend(
modulePath.includes(openaiRoot)
? { id: "codex-cli", config: { command: "codex" } }
: { id: "claude-cli", config: { command: "claude" } },
);
},
},
});
});
const first = resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} });
const second = resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} });
expect(first).toEqual({
pluginId: "openai",
backend: {
id: "codex-cli",
config: {
command: "codex",
},
},
});
expect(second).toEqual(first);
expect(resolvePluginSetupCliBackend({ backend: "legacy-openai-cli", env: {} })).toBeUndefined();
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(openaiRoot, "setup-api.js"));
});
it("keeps synchronously registered cli backends even when register returns a promise", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
cliBackends: ["codex-cli"],
requiresRuntime: true,
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerCliBackend({
id: "codex-cli",
config: { command: "codex" },
});
return Promise.resolve();
},
},
});
});
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toEqual({
pluginId: "openai",
backend: {
id: "codex-cli",
config: {
command: "codex",
},
},
});
});
it("swallows rejected async setup provider registration returns", async () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
providers: [{ id: "openai" }],
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerProvider: (provider: { id: string; label: string; auth: [] }) => void;
}) {
api.registerProvider({
id: "openai",
label: "OpenAI",
auth: [],
});
return Promise.reject(new Error("async provider register failed"));
},
},
});
});
await expectNoUnhandledRejection(() => {
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toEqual(
expect.objectContaining({
id: "openai",
label: "OpenAI",
}),
);
});
});
it("swallows rejected async setup cli backend registration returns", async () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
cliBackends: ["codex-cli"],
},
},
],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerCliBackend({
id: "codex-cli",
config: { command: "codex" },
});
return Promise.reject(new Error("async cli backend register failed"));
},
},
});
});
await expectNoUnhandledRejection(() => {
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toEqual({
pluginId: "openai",
backend: {
id: "codex-cli",
config: {
command: "codex",
},
},
});
});
});
it("swallows rejected async setup registry registration returns", async () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "voice-call", rootDir: pluginRoot }],
diagnostics: [],
});
mocks.createJiti.mockImplementation(() => {
return () => ({
default: {
register(api: {
registerConfigMigration: (migrate: (config: unknown) => unknown) => void;
}) {
api.registerConfigMigration((config) => ({ config, changes: ["voice-call"] }));
return Promise.reject(new Error("async setup registry register failed"));
},
},
});
});
await expectNoUnhandledRejection(() => {
expect(resolvePluginSetupRegistry({ env: {} }).configMigrations).toHaveLength(1);
});
});
it("fails closed when multiple plugins claim the same setup provider id", () => {
const bundledRoot = makeTempDir();
const workspaceRoot = makeTempDir();
fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
rootDir: bundledRoot,
setup: {
providers: [{ id: "openai" }],
},
},
{
id: "workspace-shadow",
origin: "workspace",
rootDir: workspaceRoot,
setup: {
providers: [{ id: "OpenAI" }],
},
},
],
diagnostics: [],
});
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toBeUndefined();
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("fails closed when duplicate plugin ids shadow the same setup provider id", () => {
const bundledRoot = makeTempDir();
const workspaceRoot = makeTempDir();
fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
rootDir: bundledRoot,
setup: {
providers: [{ id: "openai" }],
},
},
{
id: "openai",
origin: "workspace",
rootDir: workspaceRoot,
setup: {
providers: [{ id: "OpenAI" }],
},
},
],
diagnostics: [],
});
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toBeUndefined();
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("fails closed when multiple plugins claim the same setup cli backend id", () => {
const bundledRoot = makeTempDir();
const workspaceRoot = makeTempDir();
fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
rootDir: bundledRoot,
setup: {
cliBackends: ["codex-cli"],
},
},
{
id: "workspace-shadow",
origin: "workspace",
rootDir: workspaceRoot,
setup: {
cliBackends: ["CODEX-CLI"],
},
},
],
diagnostics: [],
});
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toBeUndefined();
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("fails closed when duplicate plugin ids shadow the same setup cli backend id", () => {
const bundledRoot = makeTempDir();
const workspaceRoot = makeTempDir();
fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8");
fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
rootDir: bundledRoot,
setup: {
cliBackends: ["codex-cli"],
},
},
{
id: "openai",
origin: "workspace",
rootDir: workspaceRoot,
setup: {
cliBackends: ["CODEX-CLI"],
},
},
],
diagnostics: [],
});
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toBeUndefined();
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("bounds setup lookup caches with least-recently-used eviction", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
setupRegistryTesting.setMaxSetupLookupCacheEntriesForTest(1);
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
rootDir: pluginRoot,
setup: {
providers: [{ id: "openai" }, { id: "anthropic" }],
cliBackends: ["codex-cli", "claude-cli"],
requiresRuntime: true,
},
},
],
diagnostics: [],
});
const loadSetupModule = vi.fn(() => ({
default: {
register(api: {
registerProvider: (provider: { id: string; label: string; auth: [] }) => void;
registerCliBackend: (backend: { id: string; config: { command: string } }) => void;
}) {
api.registerProvider({ id: "openai", label: "OpenAI", auth: [] });
api.registerProvider({ id: "anthropic", label: "Anthropic", auth: [] });
api.registerCliBackend({ id: "codex-cli", config: { command: "codex" } });
api.registerCliBackend({ id: "claude-cli", config: { command: "claude" } });
},
},
}));
mocks.createJiti.mockImplementation(() => loadSetupModule);
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })?.id).toBe("openai");
expect(resolvePluginSetupProvider({ provider: "anthropic", env: {} })?.id).toBe("anthropic");
expect(setupRegistryTesting.getCacheSizes().setupProvider).toBe(1);
expect(resolvePluginSetupProvider({ provider: "openai", env: {} })?.id).toBe("openai");
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })?.backend.id).toBe(
"codex-cli",
);
expect(resolvePluginSetupCliBackend({ backend: "claude-cli", env: {} })?.backend.id).toBe(
"claude-cli",
);
expect(setupRegistryTesting.getCacheSizes().setupCliBackend).toBe(1);
expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })?.backend.id).toBe(
"codex-cli",
);
resolvePluginSetupRegistry({
env: {},
pluginIds: ["openai"],
});
resolvePluginSetupRegistry({
env: {},
pluginIds: ["anthropic"],
});
expect(setupRegistryTesting.getCacheSizes().setupRegistry).toBe(1);
expect(loadSetupModule).toHaveBeenCalledTimes(7);
});
});