Files
openclaw/src/plugins/web-search-providers.runtime.test.ts
Jamil Zakirov ffb5b99114 fix: propagate workspaceDir to snapshot plugin loads (#61138)
* plugins: include resolved workspaceDir in provider hook cache keys

resolveProviderPluginsForHooks, resolveProviderPluginsForCatalogHooks, and
resolveProviderRuntimePlugin used the raw params.workspaceDir for cache keys
and plugin-id discovery while resolvePluginProviders already fell back to
the active registry workspace. Resolve workspaceDir once at the top of each
function so cache keys, candidate filtering, and loading all use the same
workspace root.

* fix(plugins): inherit runtime workspace for snapshot loads

* test(gateway): stub runtime registry seam

* fix(plugins): restore workspace fallback after rebase

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-05 09:03:54 +01:00

724 lines
22 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
type RegistryModule = typeof import("./registry.js");
type RuntimeModule = typeof import("./runtime.js");
type WebSearchProvidersRuntimeModule = typeof import("./web-search-providers.runtime.js");
type ManifestRegistryModule = typeof import("./manifest-registry.js");
type PluginAutoEnableModule = typeof import("../config/plugin-auto-enable.js");
type WebSearchProvidersSharedModule = typeof import("./web-search-providers.shared.js");
const BUNDLED_WEB_SEARCH_PROVIDERS = [
{ pluginId: "brave", id: "brave", order: 10 },
{ pluginId: "google", id: "gemini", order: 20 },
{ pluginId: "xai", id: "grok", order: 30 },
{ pluginId: "moonshot", id: "kimi", order: 40 },
{ pluginId: "perplexity", id: "perplexity", order: 50 },
{ pluginId: "firecrawl", id: "firecrawl", order: 60 },
{ pluginId: "exa", id: "exa", order: 65 },
{ pluginId: "tavily", id: "tavily", order: 70 },
{ pluginId: "duckduckgo", id: "duckduckgo", order: 100 },
] as const;
let createEmptyPluginRegistry: RegistryModule["createEmptyPluginRegistry"];
let loadPluginManifestRegistryMock: ReturnType<typeof vi.fn>;
let setActivePluginRegistry: RuntimeModule["setActivePluginRegistry"];
let resolvePluginWebSearchProviders: WebSearchProvidersRuntimeModule["resolvePluginWebSearchProviders"];
let resolveRuntimeWebSearchProviders: WebSearchProvidersRuntimeModule["resolveRuntimeWebSearchProviders"];
let resetWebSearchProviderSnapshotCacheForTests: WebSearchProvidersRuntimeModule["__testing"]["resetWebSearchProviderSnapshotCacheForTests"];
let loadOpenClawPluginsMock: ReturnType<typeof vi.fn>;
let loaderModule: typeof import("./loader.js");
let manifestRegistryModule: ManifestRegistryModule;
let pluginAutoEnableModule: PluginAutoEnableModule;
let applyPluginAutoEnableSpy: ReturnType<typeof vi.fn>;
let webSearchProvidersSharedModule: WebSearchProvidersSharedModule;
const DEFAULT_WEB_SEARCH_WORKSPACE = "/tmp/workspace";
const EXPECTED_BUNDLED_RUNTIME_WEB_SEARCH_PROVIDER_KEYS = [
"brave:brave",
"duckduckgo:duckduckgo",
"exa:exa",
"firecrawl:firecrawl",
"google:gemini",
"xai:grok",
"moonshot:kimi",
"perplexity:perplexity",
"tavily:tavily",
] as const;
function buildMockedWebSearchProviders(params?: {
config?: { plugins?: Record<string, unknown> };
}) {
const plugins = params?.config?.plugins as
| {
enabled?: boolean;
allow?: string[];
entries?: Record<string, { enabled?: boolean }>;
}
| undefined;
if (plugins?.enabled === false) {
return [];
}
const allow = Array.isArray(plugins?.allow) && plugins.allow.length > 0 ? plugins.allow : null;
const entries = plugins?.entries ?? {};
const webSearchProviders = BUNDLED_WEB_SEARCH_PROVIDERS.filter((provider) => {
if (allow && !allow.includes(provider.pluginId)) {
return false;
}
if (entries[provider.pluginId]?.enabled === false) {
return false;
}
return true;
}).map((provider) => ({
pluginId: provider.pluginId,
pluginName: provider.pluginId,
source: "test" as const,
provider: {
id: provider.id,
label: provider.id,
hint: `${provider.id} provider`,
envVars: [`${provider.id.toUpperCase()}_API_KEY`],
placeholder: `${provider.id}-...`,
signupUrl: `https://example.com/${provider.id}`,
autoDetectOrder: provider.order,
credentialPath: `plugins.entries.${provider.pluginId}.config.webSearch.apiKey`,
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({
description: provider.id,
parameters: {},
execute: async () => ({}),
}),
},
}));
return webSearchProviders;
}
function createBraveAllowConfig() {
return {
plugins: {
allow: ["brave"],
},
};
}
function createWebSearchEnv(overrides?: Partial<NodeJS.ProcessEnv>) {
return {
OPENCLAW_HOME: "/tmp/openclaw-home",
...overrides,
} as NodeJS.ProcessEnv;
}
function createSnapshotParams(params?: {
config?: { plugins?: Record<string, unknown> };
env?: NodeJS.ProcessEnv;
bundledAllowlistCompat?: boolean;
workspaceDir?: string;
}) {
return {
config: params?.config ?? createBraveAllowConfig(),
env: params?.env ?? createWebSearchEnv(),
bundledAllowlistCompat: params?.bundledAllowlistCompat ?? true,
workspaceDir: params?.workspaceDir ?? DEFAULT_WEB_SEARCH_WORKSPACE,
};
}
function toRuntimeProviderKeys(
providers: ReturnType<WebSearchProvidersRuntimeModule["resolvePluginWebSearchProviders"]>,
) {
return providers.map((provider) => `${provider.pluginId}:${provider.id}`);
}
function expectBundledRuntimeProviderKeys(
providers: ReturnType<WebSearchProvidersRuntimeModule["resolvePluginWebSearchProviders"]>,
) {
expect(toRuntimeProviderKeys(providers)).toEqual(
EXPECTED_BUNDLED_RUNTIME_WEB_SEARCH_PROVIDER_KEYS,
);
}
function createManifestRegistryFixture() {
return {
plugins: [
{
id: "brave",
origin: "bundled",
rootDir: "/tmp/brave",
source: "/tmp/brave/index.js",
manifestPath: "/tmp/brave/openclaw.plugin.json",
channels: [],
providers: [],
skills: [],
hooks: [],
configUiHints: { "webSearch.apiKey": { label: "key" } },
},
{
id: "noise",
origin: "bundled",
rootDir: "/tmp/noise",
source: "/tmp/noise/index.js",
manifestPath: "/tmp/noise/openclaw.plugin.json",
channels: [],
providers: [],
skills: [],
hooks: [],
configUiHints: { unrelated: { label: "nope" } },
},
],
diagnostics: [],
};
}
function expectLoaderCallCount(count: number) {
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(count);
}
function expectScopedWebSearchCandidates(pluginIds: readonly string[]) {
expect(loadPluginManifestRegistryMock).toHaveBeenCalled();
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [...pluginIds],
}),
);
}
function expectSnapshotMemoization(params: {
config: { plugins?: Record<string, unknown> };
env: NodeJS.ProcessEnv;
expectedLoaderCalls: number;
}) {
const runtimeParams = createSnapshotParams({
config: params.config,
env: params.env,
});
const first = resolvePluginWebSearchProviders(runtimeParams);
const second = resolvePluginWebSearchProviders(runtimeParams);
if (params.expectedLoaderCalls === 1) {
expect(second).toBe(first);
} else {
expect(second).not.toBe(first);
}
expectLoaderCallCount(params.expectedLoaderCalls);
}
function expectAutoEnabledWebSearchLoad(params: {
rawConfig: { plugins?: Record<string, unknown> };
expectedAllow: readonly string[];
}) {
expect(applyPluginAutoEnableSpy).toHaveBeenCalledWith({
config: params.rawConfig,
env: createWebSearchEnv(),
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining([...params.expectedAllow]),
}),
}),
}),
);
}
function expectSnapshotLoaderCalls(params: {
config: { plugins?: Record<string, unknown> };
env: NodeJS.ProcessEnv;
mutate: () => void;
expectedLoaderCalls: number;
}) {
resolvePluginWebSearchProviders(
createSnapshotParams({
config: params.config,
env: params.env,
}),
);
params.mutate();
resolvePluginWebSearchProviders(
createSnapshotParams({
config: params.config,
env: params.env,
}),
);
expectLoaderCallCount(params.expectedLoaderCalls);
}
function createRuntimeWebSearchProvider(params: {
pluginId: string;
pluginName: string;
id: string;
label: string;
hint: string;
envVar: string;
signupUrl: string;
credentialPath: string;
}) {
return {
pluginId: params.pluginId,
pluginName: params.pluginName,
provider: {
id: params.id,
label: params.label,
hint: params.hint,
envVars: [params.envVar],
placeholder: `${params.id}-...`,
signupUrl: params.signupUrl,
autoDetectOrder: 1,
credentialPath: params.credentialPath,
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({
description: params.id,
parameters: {},
execute: async () => ({}),
}),
},
source: "test" as const,
};
}
function expectRuntimeProviderResolution(
providers: ReturnType<WebSearchProvidersRuntimeModule["resolveRuntimeWebSearchProviders"]>,
expected: readonly string[],
) {
expect(toRuntimeProviderKeys(providers)).toEqual([...expected]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
}
describe("resolvePluginWebSearchProviders", () => {
beforeAll(async () => {
({ createEmptyPluginRegistry } = await import("./registry.js"));
manifestRegistryModule = await import("./manifest-registry.js");
loaderModule = await import("./loader.js");
pluginAutoEnableModule = await import("../config/plugin-auto-enable.js");
webSearchProvidersSharedModule = await import("./web-search-providers.shared.js");
({ setActivePluginRegistry } = await import("./runtime.js"));
({
resolvePluginWebSearchProviders,
resolveRuntimeWebSearchProviders,
__testing: { resetWebSearchProviderSnapshotCacheForTests },
} = await import("./web-search-providers.runtime.js"));
});
beforeEach(() => {
resetWebSearchProviderSnapshotCacheForTests();
applyPluginAutoEnableSpy?.mockRestore();
applyPluginAutoEnableSpy = vi
.spyOn(pluginAutoEnableModule, "applyPluginAutoEnable")
.mockImplementation(
(params) =>
({
config: params.config ?? {},
changes: [],
autoEnabledReasons: {},
}) as ReturnType<PluginAutoEnableModule["applyPluginAutoEnable"]>,
);
loadPluginManifestRegistryMock = vi
.spyOn(manifestRegistryModule, "loadPluginManifestRegistry")
.mockReturnValue(
createManifestRegistryFixture() as ManifestRegistryModule["loadPluginManifestRegistry"] extends (
...args: unknown[]
) => infer R
? R
: never,
);
loadOpenClawPluginsMock = vi
.spyOn(loaderModule, "loadOpenClawPlugins")
.mockImplementation((params) => {
const registry = createEmptyPluginRegistry();
registry.webSearchProviders = buildMockedWebSearchProviders(params);
return registry;
});
setActivePluginRegistry(createEmptyPluginRegistry());
vi.useRealTimers();
});
afterEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
vi.restoreAllMocks();
});
it("loads bundled providers through the plugin loader in alphabetical order", () => {
const providers = resolvePluginWebSearchProviders({});
expectBundledRuntimeProviderKeys(providers);
expectLoaderCallCount(1);
});
it("loads manifest-declared web-search providers in setup mode", () => {
const providers = resolvePluginWebSearchProviders({
config: {
plugins: {
allow: ["perplexity"],
},
},
mode: "setup",
});
expect(toRuntimeProviderKeys(providers)).toEqual(["brave:brave"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["brave"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["perplexity", "brave"],
entries: {
brave: { enabled: true },
},
}),
}),
}),
);
});
it("loads plugin web-search providers from the auto-enabled config snapshot", () => {
const rawConfig = createBraveAllowConfig();
const autoEnabledConfig = {
plugins: {
allow: ["brave", "perplexity"],
},
};
applyPluginAutoEnableSpy.mockReturnValue({
config: autoEnabledConfig,
changes: [],
autoEnabledReasons: {},
});
resolvePluginWebSearchProviders(createSnapshotParams({ config: rawConfig }));
expectAutoEnabledWebSearchLoad({
rawConfig,
expectedAllow: ["brave", "perplexity"],
});
});
it("scopes plugin loading to manifest-declared web-search candidates", () => {
resolvePluginWebSearchProviders({});
expectScopedWebSearchCandidates(["brave"]);
});
it("uses the active registry workspace for candidate discovery and snapshot loads when workspaceDir is omitted", () => {
const env = createWebSearchEnv();
const rawConfig = createBraveAllowConfig();
setActivePluginRegistry(
createEmptyPluginRegistry(),
undefined,
"default",
"/tmp/runtime-workspace",
);
resolvePluginWebSearchProviders({
config: rawConfig,
bundledAllowlistCompat: true,
env,
});
expect(loadPluginManifestRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/runtime-workspace",
}),
);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/runtime-workspace",
onlyPluginIds: ["brave"],
}),
);
});
it("memoizes snapshot provider resolution for the same config and env", () => {
expectSnapshotMemoization({
config: createBraveAllowConfig(),
env: createWebSearchEnv(),
expectedLoaderCalls: 1,
});
});
it("reuses a compatible active registry for snapshot resolution when config is provided", () => {
const env = createWebSearchEnv();
const rawConfig = createBraveAllowConfig();
const { config, activationSourceConfig, autoEnabledReasons } =
webSearchProvidersSharedModule.resolveBundledWebSearchResolutionConfig({
config: rawConfig,
bundledAllowlistCompat: true,
env,
});
const { cacheKey } = loaderModule.__testing.resolvePluginLoadCacheContext({
config,
activationSourceConfig,
autoEnabledReasons,
workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE,
env,
onlyPluginIds: ["brave"],
cache: false,
activate: false,
});
const registry = createEmptyPluginRegistry();
registry.webSearchProviders.push(
createRuntimeWebSearchProvider({
pluginId: "brave",
pluginName: "Brave",
id: "brave",
label: "Brave Search",
hint: "Brave runtime provider",
envVar: "BRAVE_API_KEY",
signupUrl: "https://example.com/brave",
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
}),
);
setActivePluginRegistry(registry, cacheKey);
const providers = resolvePluginWebSearchProviders({
config: rawConfig,
bundledAllowlistCompat: true,
workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE,
env,
});
expectRuntimeProviderResolution(providers, ["brave:brave"]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("inherits workspaceDir from the active registry for compatible web-search snapshot reuse", () => {
const env = createWebSearchEnv();
const rawConfig = createBraveAllowConfig();
const { config, activationSourceConfig, autoEnabledReasons } =
webSearchProvidersSharedModule.resolveBundledWebSearchResolutionConfig({
config: rawConfig,
bundledAllowlistCompat: true,
workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE,
env,
});
const { cacheKey } = loaderModule.__testing.resolvePluginLoadCacheContext({
config,
activationSourceConfig,
autoEnabledReasons,
workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE,
env,
onlyPluginIds: ["brave"],
cache: false,
activate: false,
});
const registry = createEmptyPluginRegistry();
registry.webSearchProviders.push(
createRuntimeWebSearchProvider({
pluginId: "brave",
pluginName: "Brave",
id: "brave",
label: "Brave Search",
hint: "Brave runtime provider",
envVar: "BRAVE_API_KEY",
signupUrl: "https://example.com/brave",
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
}),
);
setActivePluginRegistry(registry, cacheKey, "default", DEFAULT_WEB_SEARCH_WORKSPACE);
const providers = resolvePluginWebSearchProviders({
config: rawConfig,
bundledAllowlistCompat: true,
env,
});
expectRuntimeProviderResolution(providers, ["brave:brave"]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("keys web-search snapshot memoization by the inherited active workspace", () => {
const env = createWebSearchEnv();
const rawConfig = createBraveAllowConfig();
setActivePluginRegistry(createEmptyPluginRegistry(), undefined, "default", "/tmp/workspace-a");
resolvePluginWebSearchProviders({
config: rawConfig,
bundledAllowlistCompat: true,
env,
});
setActivePluginRegistry(createEmptyPluginRegistry(), undefined, "default", "/tmp/workspace-b");
resolvePluginWebSearchProviders({
config: rawConfig,
bundledAllowlistCompat: true,
env,
});
expectLoaderCallCount(2);
});
it.each([
{
name: "invalidates the snapshot cache when config contents change in place",
mutate: (config: { plugins?: Record<string, unknown> }, _env: NodeJS.ProcessEnv) => {
config.plugins = { allow: ["perplexity"] };
},
},
{
name: "invalidates the snapshot cache when env contents change in place",
mutate: (_config: { plugins?: Record<string, unknown> }, env: NodeJS.ProcessEnv) => {
env.OPENCLAW_HOME = "/tmp/openclaw-home-b";
},
},
] as const)("$name", ({ mutate }) => {
const config = createBraveAllowConfig();
const env = createWebSearchEnv({ OPENCLAW_HOME: "/tmp/openclaw-home-a" });
expectSnapshotLoaderCalls({
config,
env,
mutate: () => mutate(config, env),
expectedLoaderCalls: 2,
});
});
it.each([
{
title: "skips web-search snapshot memoization when plugin cache opt-outs are set",
env: {
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
},
},
{
title: "skips web-search snapshot memoization when discovery cache ttl is zero",
env: {
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0",
},
},
])("$title", ({ env }) => {
expectSnapshotMemoization({
config: createBraveAllowConfig(),
env: createWebSearchEnv(env),
expectedLoaderCalls: 2,
});
});
it("does not leak host Vitest env into an explicit non-Vitest cache key", () => {
const originalVitest = process.env.VITEST;
const config = {};
const env = createWebSearchEnv();
try {
delete process.env.VITEST;
resolvePluginWebSearchProviders(createSnapshotParams({ config, env }));
process.env.VITEST = "1";
resolvePluginWebSearchProviders(createSnapshotParams({ config, env }));
} finally {
if (originalVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = originalVitest;
}
}
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1);
});
it("expires web-search snapshot memoization after the shortest plugin cache ttl", () => {
vi.useFakeTimers();
const config = createBraveAllowConfig();
const env = createWebSearchEnv({
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5",
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "20",
});
const runtimeParams = createSnapshotParams({ config, env });
resolvePluginWebSearchProviders(runtimeParams);
vi.advanceTimersByTime(4);
resolvePluginWebSearchProviders(runtimeParams);
vi.advanceTimersByTime(2);
resolvePluginWebSearchProviders(runtimeParams);
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2);
});
it("invalidates web-search snapshots when cache-control env values change in place", () => {
const config = createBraveAllowConfig();
const env = createWebSearchEnv({
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000",
});
expectSnapshotLoaderCalls({
config,
env,
mutate: () => {
env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5";
},
expectedLoaderCalls: 2,
});
});
it.each([
{
name: "prefers the active plugin registry for runtime resolution",
setupRegistry: () => {
const registry = createEmptyPluginRegistry();
registry.webSearchProviders.push(
createRuntimeWebSearchProvider({
pluginId: "custom-search",
pluginName: "Custom Search",
id: "custom",
label: "Custom Search",
hint: "Custom runtime provider",
envVar: "CUSTOM_SEARCH_API_KEY",
signupUrl: "https://example.com/signup",
credentialPath: "tools.web.search.custom.apiKey",
}),
);
setActivePluginRegistry(registry);
},
params: {},
expected: ["custom-search:custom"],
},
{
name: "reuses a compatible active registry for runtime resolution when config is provided",
setupRegistry: () => {
const env = createWebSearchEnv();
const rawConfig = createBraveAllowConfig();
const { config, activationSourceConfig, autoEnabledReasons } =
webSearchProvidersSharedModule.resolveBundledWebSearchResolutionConfig({
config: rawConfig,
bundledAllowlistCompat: true,
env,
});
const { cacheKey } = loaderModule.__testing.resolvePluginLoadCacheContext({
config,
activationSourceConfig,
autoEnabledReasons,
workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE,
env,
onlyPluginIds: ["brave"],
cache: false,
activate: false,
});
const registry = createEmptyPluginRegistry();
registry.webSearchProviders.push(
createRuntimeWebSearchProvider({
pluginId: "brave",
pluginName: "Brave",
id: "brave",
label: "Brave Search",
hint: "Brave runtime provider",
envVar: "BRAVE_API_KEY",
signupUrl: "https://example.com/brave",
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
}),
);
setActivePluginRegistry(registry, cacheKey);
return {
config: rawConfig,
bundledAllowlistCompat: true,
workspaceDir: DEFAULT_WEB_SEARCH_WORKSPACE,
env,
};
},
expected: ["brave:brave"],
},
] as const)("$name", ({ setupRegistry, params, expected }) => {
const runtimeParams = setupRegistry() ?? params ?? {};
const providers = resolveRuntimeWebSearchProviders(runtimeParams);
expectRuntimeProviderResolution(providers, expected);
});
});