Files
openclaw/src/commands/onboard-search.providers.test.ts
2026-03-27 16:40:27 +00:00

289 lines
9.3 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
const mocks = vi.hoisted(() => ({
resolvePluginWebSearchProviders: vi.fn<
(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]
>(() => []),
listBundledWebSearchProviders: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []),
resolveBundledWebSearchPluginId: vi.fn<(providerId?: string) => string | undefined>(
() => undefined,
),
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders,
}));
vi.mock("../plugins/bundled-web-search.js", () => ({
listBundledWebSearchProviders: mocks.listBundledWebSearchProviders,
resolveBundledWebSearchPluginId: mocks.resolveBundledWebSearchPluginId,
}));
function createCustomProviderEntry(): PluginWebSearchProviderEntry {
return {
id: "custom-search" as never,
pluginId: "custom-plugin",
label: "Custom Search",
hint: "Custom provider",
envVars: ["CUSTOM_SEARCH_API_KEY"],
placeholder: "custom-...",
signupUrl: "https://example.com/custom",
credentialPath: "plugins.entries.custom-plugin.config.webSearch.apiKey",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
getConfiguredCredentialValue: (config) =>
(
config?.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
const entries = ((configTarget.plugins ??= {}).entries ??= {});
const pluginEntry = (entries["custom-plugin"] ??= {});
const pluginConfig = ((pluginEntry as Record<string, unknown>).config ??= {}) as Record<
string,
unknown
>;
const webSearch = (pluginConfig.webSearch ??= {}) as Record<string, unknown>;
webSearch.apiKey = value;
},
createTool: () => null,
};
}
function createBundledFirecrawlEntry(): PluginWebSearchProviderEntry {
return {
id: "firecrawl",
pluginId: "firecrawl",
label: "Firecrawl Search",
hint: "Structured results",
onboardingScopes: ["text-inference"],
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://example.com/firecrawl",
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
getConfiguredCredentialValue: (config) =>
(
config?.plugins?.entries?.firecrawl?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
setConfiguredCredentialValue: () => {},
createTool: () => null,
};
}
function createBundledDuckDuckGoEntry(): PluginWebSearchProviderEntry {
return {
id: "duckduckgo",
pluginId: "duckduckgo",
label: "DuckDuckGo Search (experimental)",
hint: "Free fallback",
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
signupUrl: "https://duckduckgo.com/",
credentialPath: "",
getCredentialValue: () => "duckduckgo-no-key-needed",
setCredentialValue: () => {},
createTool: () => null,
};
}
describe("onboard-search provider resolution", () => {
let mod: typeof import("./onboard-search.js");
beforeAll(async () => {
vi.resetModules();
mod = await import("./onboard-search.js");
});
afterEach(() => {
vi.clearAllMocks();
});
it("uses config-aware non-bundled provider hooks when resolving existing keys", async () => {
const customEntry = createCustomProviderEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [customEntry] : [],
);
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "custom-search" as never,
},
},
},
plugins: {
entries: {
"custom-plugin": {
config: {
webSearch: {
apiKey: "custom-key",
},
},
},
},
},
};
expect(mod.hasExistingKey(cfg, "custom-search" as never)).toBe(true);
expect(mod.resolveExistingKey(cfg, "custom-search" as never)).toBe("custom-key");
const updated = mod.applySearchKey(cfg, "custom-search" as never, "next-key");
expect(
(
updated.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
).toBe("next-key");
});
it("uses config-aware non-bundled providers when building secret refs", async () => {
const customEntry = createCustomProviderEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [customEntry] : [],
);
const cfg: OpenClawConfig = {
plugins: {
installs: {
"custom-plugin": {
installPath: "/tmp/custom-plugin",
source: "path",
},
},
},
};
const notes: Array<{ title?: string; message: string }> = [];
const prompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message: string, title?: string) => {
notes.push({ title, message });
}),
select: vi.fn(async () => "custom-search"),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => ""),
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const result = await mod.setupSearch(cfg, {} as never, prompter as never, {
secretInputMode: "ref",
});
expect(result.tools?.web?.search?.provider).toBe("custom-search");
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(
(
result.plugins?.entries?.["custom-plugin"]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey,
).toEqual({
source: "env",
provider: "default",
id: "CUSTOM_SEARCH_API_KEY",
});
expect(notes.some((note) => note.message.includes("CUSTOM_SEARCH_API_KEY"))).toBe(true);
});
it("does not treat hard-disabled bundled providers as selectable credentials", async () => {
const firecrawlEntry = createBundledFirecrawlEntry();
mocks.resolvePluginWebSearchProviders.mockReturnValue([]);
mocks.listBundledWebSearchProviders.mockReturnValue([firecrawlEntry]);
mocks.resolveBundledWebSearchPluginId.mockReturnValue("firecrawl");
const cfg: OpenClawConfig = {
tools: {
web: {
search: {
provider: "firecrawl",
},
},
},
plugins: {
enabled: false,
entries: {
firecrawl: {
config: {
webSearch: {
apiKey: "fc-disabled-key",
},
},
},
},
},
};
expect(mod.hasExistingKey(cfg, "firecrawl")).toBe(false);
expect(mod.resolveExistingKey(cfg, "firecrawl")).toBeUndefined();
expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg);
});
it("defaults to a keyless provider when no search credentials exist", async () => {
const duckduckgoEntry = createBundledDuckDuckGoEntry();
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
params?.config ? [duckduckgoEntry] : [duckduckgoEntry],
);
const notes: string[] = [];
const prompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async (message: string) => {
notes.push(message);
}),
select: vi.fn(async () => "duckduckgo"),
multiselect: vi.fn(async () => []),
text: vi.fn(async () => {
throw new Error("text prompt should not run for keyless providers");
}),
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const result = await mod.setupSearch({} as OpenClawConfig, {} as never, prompter as never);
expect(result.tools?.web?.search?.provider).toBe("duckduckgo");
expect(result.plugins?.entries?.duckduckgo?.enabled).toBe(true);
expect(notes.some((message) => message.includes("works without an API key"))).toBe(true);
});
it("keeps the legacy default onboarding search surface when no config is present", async () => {
const firecrawlEntry = createBundledFirecrawlEntry();
const duckduckgoEntry = createBundledDuckDuckGoEntry();
const tavilyEntry: PluginWebSearchProviderEntry = {
...firecrawlEntry,
id: "tavily",
pluginId: "tavily",
label: "Tavily Search",
hint: "Research search",
envVars: ["TAVILY_API_KEY"],
signupUrl: "https://example.com/tavily",
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
};
const customEntry = createCustomProviderEntry();
mocks.listBundledWebSearchProviders.mockReturnValue([
customEntry,
duckduckgoEntry,
firecrawlEntry,
tavilyEntry,
]);
mocks.resolvePluginWebSearchProviders.mockReturnValue([customEntry]);
const options = mod.resolveSearchProviderOptions();
expect(options.map((entry) => entry.id)).toEqual(["firecrawl", "tavily"]);
});
});