test: move extension-owned coverage out of core

This commit is contained in:
Peter Steinberger 2026-04-03 10:55:47 +01:00
parent 2bfbddb81f
commit 64755c52f2
No known key found for this signature in database
8 changed files with 410 additions and 197 deletions

View File

@ -1,6 +1,14 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js";
const braveManifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
) as {
configSchema?: Record<string, unknown>;
};
describe("brave web search provider", () => {
const priorFetch = global.fetch;
@ -58,6 +66,51 @@ describe("brave web search provider", () => {
expect(__testing.resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
});
it("accepts llm-context in the Brave plugin config schema", () => {
if (!braveManifest.configSchema) {
throw new Error("Expected Brave manifest config schema");
}
const result = validateJsonSchemaValue({
schema: braveManifest.configSchema,
cacheKey: "test:brave-config-schema",
value: {
webSearch: {
mode: "llm-context",
},
},
});
expect(result.ok).toBe(true);
});
it("rejects invalid Brave mode values in the plugin config schema", () => {
if (!braveManifest.configSchema) {
throw new Error("Expected Brave manifest config schema");
}
const result = validateJsonSchemaValue({
schema: braveManifest.configSchema,
cacheKey: "test:brave-config-schema",
value: {
webSearch: {
mode: "invalid-mode",
},
},
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.errors).toContainEqual(
expect.objectContaining({
path: "webSearch.mode",
allowedValues: ["web", "llm-context"],
}),
);
});
it("maps llm-context results into wrapped source entries", () => {
expect(
__testing.mapBraveLlmContextResults({

View File

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../src/config/config.js";
import { withEnv } from "../../test/helpers/plugins/env.js";
import { __testing, createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
describe("google web search provider", () => {
it("falls back to GEMINI_API_KEY from the environment", () => {
withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => {
expect(__testing.resolveGeminiApiKey()).toBe("AIza-env-test");
});
});
it("prefers configured api keys over env fallbacks", () => {
withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => {
expect(__testing.resolveGeminiApiKey({ apiKey: "AIza-configured-test" })).toBe(
"AIza-configured-test",
);
});
});
it("stores configured credentials at the canonical plugin config path", () => {
const provider = createGeminiWebSearchProvider();
const config = {} as OpenClawConfig;
provider.setConfiguredCredentialValue?.(config, "AIza-plugin-test");
expect(provider.credentialPath).toBe("plugins.entries.google.config.webSearch.apiKey");
expect(provider.getConfiguredCredentialValue?.(config)).toBe("AIza-plugin-test");
});
it("defaults the Gemini web search model and trims explicit overrides", () => {
expect(__testing.resolveGeminiModel()).toBe("gemini-2.5-flash");
expect(__testing.resolveGeminiModel({ model: " gemini-2.5-pro " })).toBe("gemini-2.5-pro");
});
});

View File

@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
import { resetPluginRuntimeStateForTest } from "../../plugins/runtime.js";
const fallbackState = vi.hoisted(() => ({
activeDirName: null as string | null,
resolveSessionConversation: null as
| ((params: { kind: "group" | "channel"; rawId: string }) => {
id: string;
threadId?: string | null;
baseConversationId?: string | null;
parentConversationCandidates?: string[];
} | null)
| null,
}));
vi.mock("../../plugin-sdk/facade-runtime.js", () => ({
tryLoadActivatedBundledPluginPublicSurfaceModuleSync: ({ dirName }: { dirName: string }) =>
dirName === fallbackState.activeDirName && fallbackState.resolveSessionConversation
? { resolveSessionConversation: fallbackState.resolveSessionConversation }
: null,
}));
vi.mock("../../plugins/bundled-plugin-metadata.js", () => ({
resolveBundledPluginPublicSurfacePath: ({ dirName }: { dirName: string }) =>
dirName === fallbackState.activeDirName ? `/tmp/${dirName}/session-key-api.js` : null,
}));
import { resolveSessionConversationRef } from "./session-conversation.js";
describe("session conversation bundled fallback", () => {
beforeEach(() => {
fallbackState.activeDirName = null;
fallbackState.resolveSessionConversation = null;
resetPluginRuntimeStateForTest();
});
afterEach(() => {
clearRuntimeConfigSnapshot();
});
it("delegates pre-bootstrap thread parsing to the active bundled channel plugin", () => {
fallbackState.activeDirName = "mock-threaded";
fallbackState.resolveSessionConversation = ({ rawId }) => {
const [conversationId, threadId] = rawId.split(":topic:");
return {
id: conversationId,
threadId,
baseConversationId: conversationId,
parentConversationCandidates: [conversationId],
};
};
setRuntimeConfigSnapshot({
plugins: {
entries: {
"mock-threaded": {
enabled: true,
},
},
},
});
expect(resolveSessionConversationRef("agent:main:mock-threaded:group:room:topic:42")).toEqual({
channel: "mock-threaded",
kind: "group",
rawId: "room:topic:42",
id: "room",
threadId: "42",
baseSessionKey: "agent:main:mock-threaded:group:room",
baseConversationId: "room",
parentConversationCandidates: ["room"],
});
});
it("uses explicit bundled parent candidates before registry bootstrap", () => {
fallbackState.activeDirName = "mock-parent";
fallbackState.resolveSessionConversation = ({ rawId }) => ({
id: rawId,
baseConversationId: "room",
parentConversationCandidates: ["room:topic:root", "room"],
});
setRuntimeConfigSnapshot({
plugins: {
entries: {
"mock-parent": {
enabled: true,
},
},
},
});
expect(
resolveSessionConversationRef("agent:main:mock-parent:group:room:topic:root:sender:user"),
).toEqual({
channel: "mock-parent",
kind: "group",
rawId: "room:topic:root:sender:user",
id: "room:topic:root:sender:user",
threadId: undefined,
baseSessionKey: "agent:main:mock-parent:group:room:topic:root:sender:user",
baseConversationId: "room",
parentConversationCandidates: ["room:topic:root", "room"],
});
});
});

View File

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
import { clearRuntimeConfigSnapshot } from "../../config/config.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js";
@ -54,57 +54,6 @@ describe("session conversation routing", () => {
);
});
it("keeps bundled Telegram topic parsing available before registry bootstrap", () => {
resetPluginRuntimeStateForTest();
setRuntimeConfigSnapshot({
channels: {
telegram: {
enabled: true,
},
},
});
expect(resolveSessionConversationRef("agent:main:telegram:group:-100123:topic:77")).toEqual({
channel: "telegram",
kind: "group",
rawId: "-100123:topic:77",
id: "-100123",
threadId: "77",
baseSessionKey: "agent:main:telegram:group:-100123",
baseConversationId: "-100123",
parentConversationCandidates: ["-100123"],
});
});
it("keeps bundled Feishu parent fallbacks available before registry bootstrap", () => {
resetPluginRuntimeStateForTest();
setRuntimeConfigSnapshot({
plugins: {
entries: {
feishu: {
enabled: true,
},
},
},
});
expect(
resolveSessionConversationRef(
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
),
).toEqual({
channel: "feishu",
kind: "group",
rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
threadId: undefined,
baseSessionKey:
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
baseConversationId: "oc_group_chat",
parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"],
});
});
it("does not load bundled session-key fallbacks for inactive channel plugins", () => {
resetPluginRuntimeStateForTest();

View File

@ -91,10 +91,83 @@ vi.mock("../plugins/web-search-providers.js", () => {
};
});
const secretInputSchema = {
oneOf: [
{ type: "string" },
{
type: "object",
additionalProperties: false,
properties: {
source: { type: "string" },
provider: { type: "string" },
id: { type: "string" },
},
required: ["source", "provider", "id"],
},
],
};
function buildWebSearchPluginSchema() {
return {
type: "object",
additionalProperties: false,
properties: {
webSearch: {
type: "object",
additionalProperties: false,
properties: {
apiKey: secretInputSchema,
baseUrl: secretInputSchema,
model: { type: "string" },
},
},
},
};
}
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: () => ({
plugins: [
{
id: "brave",
origin: "bundled",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: "/tmp/plugins/brave",
source: "test",
manifestPath: "/tmp/plugins/brave/openclaw.plugin.json",
schemaCacheKey: "test:brave",
configSchema: buildWebSearchPluginSchema(),
},
...["firecrawl", "google", "moonshot", "perplexity", "searxng", "tavily", "xai"].map(
(id) => ({
id,
origin: "bundled",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: `/tmp/plugins/${id}`,
source: "test",
manifestPath: `/tmp/plugins/${id}/openclaw.plugin.json`,
schemaCacheKey: `test:${id}`,
configSchema: buildWebSearchPluginSchema(),
}),
),
],
diagnostics: [],
}),
}));
let validateConfigObjectWithPlugins: typeof import("./config.js").validateConfigObjectWithPlugins;
let resolveSearchProvider: typeof import("../agents/tools/web-search.js").__testing.resolveSearchProvider;
beforeAll(async () => {
vi.resetModules();
({ validateConfigObjectWithPlugins } = await import("./config.js"));
({
__testing: { resolveSearchProvider },
@ -257,32 +330,6 @@ describe("web search provider config", () => {
expect(res.ok).toBe(true);
});
it("accepts brave llm-context mode config", () => {
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "brave",
providerConfig: {
mode: "llm-context",
},
}),
);
expect(res.ok).toBe(true);
});
it("rejects invalid brave mode config values", () => {
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "brave",
providerConfig: {
mode: "invalid-mode",
},
}),
);
expect(res.ok).toBe(false);
});
});
describe("web search provider auto-detection", () => {

View File

@ -3,6 +3,105 @@ import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import { createNonExitingRuntime } from "../runtime.js";
import { runSearchSetupFlow } from "./search-setup.js";
const mockGrokProvider = vi.hoisted(() => ({
id: "grok",
pluginId: "xai",
label: "Grok",
hint: "Search with xAI",
docsUrl: "https://docs.openclaw.ai/tools/web",
requiresCredential: true,
credentialLabel: "xAI API key",
placeholder: "xai-...",
signupUrl: "https://x.ai/api",
envVars: ["XAI_API_KEY"],
onboardingScopes: ["text-inference"],
credentialPath: "plugins.entries.xai.config.webSearch.apiKey",
getCredentialValue: (search?: Record<string, unknown>) => search?.apiKey,
setCredentialValue: (searchConfigTarget: Record<string, unknown>, value: unknown) => {
searchConfigTarget.apiKey = value;
},
getConfiguredCredentialValue: (config?: Record<string, unknown>) =>
(
config?.plugins as
| {
entries?: Record<
string,
{
config?: {
webSearch?: { apiKey?: unknown };
};
}
>;
}
| undefined
)?.entries?.xai?.config?.webSearch?.apiKey,
setConfiguredCredentialValue: (configTarget: Record<string, unknown>, value: unknown) => {
const plugins = (configTarget.plugins ??= {}) as Record<string, unknown>;
const entries = (plugins.entries ??= {}) as Record<string, unknown>;
const xaiEntry = (entries.xai ??= {}) as Record<string, unknown>;
const xaiConfig = (xaiEntry.config ??= {}) as Record<string, unknown>;
const webSearch = (xaiConfig.webSearch ??= {}) as Record<string, unknown>;
webSearch.apiKey = value;
},
runSetup: async ({
config,
prompter,
}: {
config: Record<string, unknown>;
prompter: { select: (params: Record<string, unknown>) => Promise<string> };
}) => {
const enableXSearch = await prompter.select({
message: "Enable x_search",
options: [
{ value: "yes", label: "Yes" },
{ value: "no", label: "No" },
],
});
if (enableXSearch !== "yes") {
return config;
}
const model = await prompter.select({
message: "Grok model",
options: [{ value: "grok-4-1-fast", label: "grok-4-1-fast" }],
});
const pluginEntries = (config.plugins as { entries?: Record<string, unknown> } | undefined)
?.entries;
const existingXaiEntry = pluginEntries?.xai as Record<string, unknown> | undefined;
const existingXaiConfig = (
pluginEntries?.xai as { config?: Record<string, unknown> } | undefined
)?.config;
return {
...config,
plugins: {
...(config.plugins as Record<string, unknown> | undefined),
entries: {
...pluginEntries,
xai: {
...existingXaiEntry,
config: {
...existingXaiConfig,
xSearch: {
enabled: true,
model,
},
},
},
},
},
};
},
}));
vi.mock("../plugins/bundled-web-search.js", () => ({
listBundledWebSearchProviders: () => [mockGrokProvider],
resolveBundledWebSearchPluginId: (providerId: string | undefined) =>
providerId === "grok" ? "xai" : undefined,
}));
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: () => [mockGrokProvider],
}));
describe("runSearchSetupFlow", () => {
it("runs provider-owned setup after selecting Grok web search", async () => {
const select = vi

View File

@ -11,11 +11,15 @@ import {
writeConfigFile,
} from "../config/config.js";
import { withTempHome } from "../config/home-env.test-harness.js";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import { clearPluginLoaderCache } from "../plugins/loader.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import { __testing as webFetchProvidersTesting } from "../plugins/web-fetch-providers.runtime.js";
import { __testing as webSearchProvidersTesting } from "../plugins/web-search-providers.runtime.js";
import { captureEnv, withEnvAsync } from "../test-utils/env.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
getActiveRuntimeWebToolsMetadata,
getActiveSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "./runtime.js";
@ -119,6 +123,7 @@ describe("secrets runtime snapshot integration", () => {
beforeEach(() => {
envSnapshot = captureEnv([
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
"OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE",
"OPENCLAW_VERSION",
]);
@ -133,6 +138,11 @@ describe("secrets runtime snapshot integration", () => {
clearSecretsRuntimeSnapshot();
clearRuntimeConfigSnapshot();
clearConfigCache();
clearPluginLoaderCache();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
webSearchProvidersTesting.resetWebSearchProviderSnapshotCacheForTests();
webFetchProvidersTesting.resetWebFetchProviderSnapshotCacheForTests();
});
it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => {
@ -346,107 +356,6 @@ describe("secrets runtime snapshot integration", () => {
SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS,
);
it(
"keeps last-known-good web runtime snapshot when reload introduces unresolved active web refs",
async () => {
await withTempHome("openclaw-secrets-runtime-web-reload-lkg-", async (home) => {
const prepared = await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
search: {
provider: "gemini",
},
},
},
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: {
source: "env",
provider: "default",
id: "WEB_SEARCH_GEMINI_API_KEY",
},
},
},
},
},
},
}),
env: {
WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-runtime-key",
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
activateSecretsRuntimeSnapshot(prepared);
await expect(
writeConfigFile({
...loadConfig(),
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: {
source: "env",
provider: "default",
id: "MISSING_WEB_SEARCH_GEMINI_API_KEY",
},
},
},
},
},
},
tools: {
web: {
search: {
provider: "gemini",
},
},
},
}),
).rejects.toThrow(
/runtime snapshot refresh failed: .*WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK/i,
);
const activeAfterFailure = getActiveSecretsRuntimeSnapshot();
expect(activeAfterFailure).not.toBeNull();
const loadedGoogleWebSearchConfig = loadConfig().plugins?.entries?.google?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined;
expect(loadedGoogleWebSearchConfig?.webSearch?.apiKey).toBe(
"web-search-gemini-runtime-key",
);
const activeSourceGoogleWebSearchConfig = activeAfterFailure?.sourceConfig.plugins?.entries
?.google?.config as { webSearch?: { apiKey?: unknown } } | undefined;
expect(activeSourceGoogleWebSearchConfig?.webSearch?.apiKey).toEqual({
source: "env",
provider: "default",
id: "WEB_SEARCH_GEMINI_API_KEY",
});
expect(getActiveRuntimeWebToolsMetadata()?.search.selectedProvider).toBe("gemini");
const persistedConfig = JSON.parse(
await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"),
) as OpenClawConfig;
const persistedGoogleWebSearchConfig = persistedConfig.plugins?.entries?.google?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined;
expect(persistedGoogleWebSearchConfig?.webSearch?.apiKey).toEqual({
source: "env",
provider: "default",
id: "MISSING_WEB_SEARCH_GEMINI_API_KEY",
});
});
},
SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS,
);
it("recomputes config-derived agent dirs when refreshing active secrets runtime snapshots", async () => {
await withTempHome("openclaw-secrets-runtime-agent-dirs-", async (home) => {
const mainAgentDir = path.join(home, ".openclaw", "agents", "main", "agent");

View File

@ -1,24 +1,40 @@
import { loadBundledPluginPublicSurfaceSync } from "./bundled-plugin-public-surface.js";
import { createTestRegistry } from "./channel-plugins.js";
type SessionConversationSurface = {
resolveSessionConversation?: (params: { kind: "group" | "channel"; rawId: string }) => {
id: string;
threadId?: string | null;
baseConversationId?: string | null;
parentConversationCandidates?: string[];
} | null;
};
function loadSessionConversationSurface(pluginId: string) {
return loadBundledPluginPublicSurfaceSync<SessionConversationSurface>({
pluginId,
artifactBasename: "session-key-api.js",
}).resolveSessionConversation;
function resolveTelegramSessionConversation(params: { kind: "group" | "channel"; rawId: string }) {
if (params.kind !== "group") {
return null;
}
const match = params.rawId.match(/^(?<chatId>.+):topic:(?<topicId>[^:]+)$/u);
if (!match?.groups?.chatId || !match.groups.topicId) {
return null;
}
const chatId = match.groups.chatId;
return {
id: chatId,
threadId: match.groups.topicId,
baseConversationId: chatId,
parentConversationCandidates: [chatId],
};
}
const resolveTelegramSessionConversation = loadSessionConversationSurface("telegram");
const resolveFeishuSessionConversation = loadSessionConversationSurface("feishu");
function resolveFeishuSessionConversation(params: { kind: "group" | "channel"; rawId: string }) {
if (params.kind !== "group") {
return null;
}
const senderMatch = params.rawId.match(
/^(?<chatId>[^:]+):topic:(?<topicId>[^:]+):sender:(?<senderId>[^:]+)$/u,
);
if (!senderMatch?.groups?.chatId || !senderMatch.groups.topicId || !senderMatch.groups.senderId) {
return null;
}
const chatId = senderMatch.groups.chatId;
const topicId = senderMatch.groups.topicId;
return {
id: params.rawId,
baseConversationId: chatId,
parentConversationCandidates: [`${chatId}:topic:${topicId}`, chatId],
};
}
export function createSessionConversationTestRegistry() {
return createTestRegistry([