openclaw/src/plugins/web-search-providers.runtim...

549 lines
17 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: unknown }) =>
({
config: params.config,
changes: [],
}) 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 plugin web-search providers from the auto-enabled config snapshot", () => {
const rawConfig = createBraveAllowConfig();
const autoEnabledConfig = {
plugins: {
allow: ["brave", "perplexity"],
},
};
applyPluginAutoEnableSpy.mockReturnValue({ config: autoEnabledConfig, changes: [] });
resolvePluginWebSearchProviders(createSnapshotParams({ config: rawConfig }));
expectAutoEnabledWebSearchLoad({
rawConfig,
expectedAllow: ["brave", "perplexity"],
});
});
it("scopes plugin loading to manifest-declared web-search candidates", () => {
resolvePluginWebSearchProviders({});
expectScopedWebSearchCandidates(["brave"]);
});
it("memoizes snapshot provider resolution for the same config and env", () => {
expectSnapshotMemoization({
config: createBraveAllowConfig(),
env: createWebSearchEnv(),
expectedLoaderCalls: 1,
});
});
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 { config } = webSearchProvidersSharedModule.resolveBundledWebSearchResolutionConfig({
config: createBraveAllowConfig(),
bundledAllowlistCompat: true,
env,
});
const { cacheKey } = loaderModule.__testing.resolvePluginLoadCacheContext({
config,
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: "tools.web.search.brave.apiKey",
}),
);
setActivePluginRegistry(registry, cacheKey);
return {
config: createBraveAllowConfig(),
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);
});
});