test: dedupe plugin contract suites

This commit is contained in:
Peter Steinberger
2026-03-28 06:04:38 +00:00
parent 48b2291b1e
commit 7206ddea6f
9 changed files with 500 additions and 519 deletions

View File

@@ -23,6 +23,44 @@ vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
}));
function createAuthChoiceProvider(params: {
providerId: string;
label: string;
methodId: string;
methodLabel: string;
kind: "oauth" | "api_key" | "custom";
}) {
return {
id: params.providerId,
label: params.label,
auth: [
{
id: params.methodId,
label: params.methodLabel,
hint:
params.kind === "api_key"
? "Paste key"
: params.kind === "custom"
? "No auth"
: "Browser sign-in",
kind: params.kind,
run: runAuthMethodMock,
},
],
} satisfies ProviderPlugin;
}
async function expectPreferredProviderFallback(provider: ProviderPlugin) {
resolvePluginProvidersMock.mockClear();
resolvePluginProvidersMock.mockReturnValue([provider]);
await expect(
resolvePreferredProviderForAuthChoice({
choice: buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default"),
}),
).resolves.toBe(provider.id);
expect(resolvePluginProvidersMock).toHaveBeenCalled();
}
describe("provider auth-choice contract", () => {
beforeEach(() => {
resolvePluginProvidersMock.mockReset();
@@ -57,69 +95,38 @@ describe("provider auth-choice contract", () => {
it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => {
const pluginFallbackScenarios: ProviderPlugin[] = [
{
id: "demo-oauth-provider",
createAuthChoiceProvider({
providerId: "demo-oauth-provider",
label: "Demo OAuth Provider",
auth: [
{
id: "oauth",
label: "OAuth",
hint: "Browser sign-in",
kind: "oauth",
run: runAuthMethodMock,
},
],
},
{
id: "demo-browser-provider",
methodId: "oauth",
methodLabel: "OAuth",
kind: "oauth",
}),
createAuthChoiceProvider({
providerId: "demo-browser-provider",
label: "Demo Browser Provider",
auth: [
{
id: "portal",
label: "Portal",
hint: "Browser sign-in",
kind: "oauth",
run: runAuthMethodMock,
},
],
},
{
id: "demo-api-key-provider",
methodId: "portal",
methodLabel: "Portal",
kind: "oauth",
}),
createAuthChoiceProvider({
providerId: "demo-api-key-provider",
label: "Demo API Key Provider",
auth: [
{
id: "api-key",
label: "API key",
hint: "Paste key",
kind: "api_key",
run: runAuthMethodMock,
},
],
},
{
id: "demo-local-provider",
methodId: "api-key",
methodLabel: "API key",
kind: "api_key",
}),
createAuthChoiceProvider({
providerId: "demo-local-provider",
label: "Demo Local Provider",
auth: [
{
id: "local",
label: "Local",
hint: "No auth",
kind: "custom",
run: runAuthMethodMock,
},
],
},
methodId: "local",
methodLabel: "Local",
kind: "custom",
}),
];
for (const provider of pluginFallbackScenarios) {
resolvePluginProvidersMock.mockClear();
resolvePluginProvidersMock.mockReturnValue([provider]);
await expect(
resolvePreferredProviderForAuthChoice({
choice: buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default"),
}),
).resolves.toBe(provider.id);
expect(resolvePluginProvidersMock).toHaveBeenCalled();
await expectPreferredProviderFallback(provider);
}
resolvePluginProvidersMock.mockClear();

View File

@@ -64,6 +64,45 @@ function setGithubCopilotProfileSnapshot() {
});
}
function createNoAuthResolution() {
return {
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none" as const,
source: "none" as const,
};
}
function createResolvedAuth(params: {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
}) {
return {
apiKey: params.apiKey,
discoveryApiKey: params.discoveryApiKey,
mode: params.mode,
source: params.source,
...(params.profileId ? { profileId: params.profileId } : {}),
};
}
function createNoAuthCatalogParams(
provider: Awaited<ReturnType<typeof requireProvider>>,
overrides: Partial<Parameters<typeof runProviderCatalog>[0]> = {},
) {
return {
provider,
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => createNoAuthResolution(),
...overrides,
};
}
function runCatalog(params: {
provider: Awaited<ReturnType<typeof requireProvider>>;
env?: NodeJS.ProcessEnv;
@@ -202,7 +241,7 @@ describe("provider discovery contract", () => {
});
it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => {
await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull();
await expect(runCatalog(createNoAuthCatalogParams(githubCopilotProvider))).resolves.toBeNull();
});
it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => {
@@ -210,7 +249,7 @@ describe("provider discovery contract", () => {
await expect(
runCatalog({
provider: githubCopilotProvider,
...createNoAuthCatalogParams(githubCopilotProvider),
}),
).resolves.toEqual({
provider: {
@@ -252,24 +291,17 @@ describe("provider discovery contract", () => {
it("keeps Ollama explicit catalog normalization provider-owned", async () => {
await expect(
runProviderCatalog({
provider: ollamaProvider,
config: {
models: {
providers: {
ollama: {
baseUrl: "http://ollama-host:11434/v1/",
models: [createModelConfig("llama3.2")],
...createNoAuthCatalogParams(ollamaProvider, {
config: {
models: {
providers: {
ollama: {
baseUrl: "http://ollama-host:11434/v1/",
models: [createModelConfig("llama3.2")],
},
},
},
},
},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toMatchObject({
@@ -290,98 +322,99 @@ describe("provider discovery contract", () => {
models: [],
});
await expect(
runProviderCatalog({
provider: ollamaProvider,
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
await expect(runProviderCatalog(createNoAuthCatalogParams(ollamaProvider))).resolves.toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
});
it("keeps vLLM self-hosted discovery provider-owned", async () => {
buildVllmProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
});
await expect(
runProviderCatalog({
provider: vllmProvider,
config: {},
env: {
VLLM_API_KEY: "env-vllm-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
}),
resolveProviderAuth: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
it.each([
{
name: "keeps vLLM self-hosted discovery provider-owned",
provider: () => vllmProvider,
buildProviderMock: buildVllmProviderMock,
builtProvider: {
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
apiKey: "VLLM_API_KEY",
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
},
});
expect(buildVllmProviderMock).toHaveBeenCalledWith({
apiKey: "env-vllm-key",
});
});
it("keeps SGLang self-hosted discovery provider-owned", async () => {
buildSglangProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:30000/v1",
api: "openai-completions",
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
});
await expect(
runProviderCatalog({
provider: sglangProvider,
config: {},
env: {
SGLANG_API_KEY: "env-sglang-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
}),
resolveProviderAuth: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
mode: "api_key",
source: "env",
}),
env: {
VLLM_API_KEY: "env-vllm-key",
} as NodeJS.ProcessEnv,
resolvedAuth: createResolvedAuth({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
mode: "api_key",
source: "env",
}),
).resolves.toEqual({
provider: {
expected: {
provider: {
baseUrl: "http://127.0.0.1:8000/v1",
api: "openai-completions",
apiKey: "VLLM_API_KEY",
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
},
},
expectedBuildCall: {
apiKey: "env-vllm-key",
},
},
{
name: "keeps SGLang self-hosted discovery provider-owned",
provider: () => sglangProvider,
buildProviderMock: buildSglangProviderMock,
builtProvider: {
baseUrl: "http://127.0.0.1:30000/v1",
api: "openai-completions",
apiKey: "SGLANG_API_KEY",
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
},
});
expect(buildSglangProviderMock).toHaveBeenCalledWith({
apiKey: "env-sglang-key",
});
});
env: {
SGLANG_API_KEY: "env-sglang-key",
} as NodeJS.ProcessEnv,
resolvedAuth: createResolvedAuth({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
mode: "api_key",
source: "env",
}),
expected: {
provider: {
baseUrl: "http://127.0.0.1:30000/v1",
api: "openai-completions",
apiKey: "SGLANG_API_KEY",
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
},
},
expectedBuildCall: {
apiKey: "env-sglang-key",
},
},
] as const)(
"$name",
async ({
provider,
buildProviderMock,
builtProvider,
env,
resolvedAuth,
expected,
expectedBuildCall,
}) => {
buildProviderMock.mockResolvedValueOnce(builtProvider);
await expect(
runProviderCatalog(
createNoAuthCatalogParams(provider(), {
env,
resolveProviderApiKey: () => ({
apiKey: resolvedAuth.apiKey,
discoveryApiKey: resolvedAuth.discoveryApiKey,
}),
resolveProviderAuth: () => resolvedAuth,
}),
),
).resolves.toEqual(expected);
expect(buildProviderMock).toHaveBeenCalledWith(expectedBuildCall);
},
);
it("keeps MiniMax API catalog provider-owned", async () => {
await expect(

View File

@@ -26,6 +26,17 @@ function expectPluginAllowlistContains(
}
}
function createAllowlistCompatConfig(pluginIds: string[]) {
return withBundledPluginAllowlistCompat({
config: {
plugins: {
allow: [demoAllowEntry],
},
},
pluginIds,
});
}
const demoAllowEntry = "demo-allowed";
describe("plugin loader contract", () => {
@@ -48,14 +59,7 @@ describe("plugin loader contract", () => {
},
},
});
compatConfig = withBundledPluginAllowlistCompat({
config: {
plugins: {
allow: [demoAllowEntry],
},
},
pluginIds: compatPluginIds,
});
compatConfig = createAllowlistCompatConfig(compatPluginIds);
vitestCompatConfig = providerTesting.withBundledProviderVitestCompat({
config: undefined,
pluginIds: providerPluginIds,
@@ -65,14 +69,7 @@ describe("plugin loader contract", () => {
resolveBundledPluginWebSearchProviders({}).map((entry) => entry.pluginId),
);
bundledWebSearchPluginIds = uniqueSortedStrings(resolveBundledWebSearchPluginIds({}));
webSearchAllowlistCompatConfig = withBundledPluginAllowlistCompat({
config: {
plugins: {
allow: [demoAllowEntry],
},
},
pluginIds: webSearchPluginIds,
});
webSearchAllowlistCompatConfig = createAllowlistCompatConfig(webSearchPluginIds);
});
beforeEach(() => {
@@ -81,8 +78,9 @@ describe("plugin loader contract", () => {
it("keeps bundled provider compatibility wired to the provider registry", () => {
expect(providerPluginIds).toEqual(manifestProviderPluginIds);
expect(uniqueSortedStrings(compatPluginIds)).toEqual(manifestProviderPluginIds);
expect(uniqueSortedStrings(compatPluginIds)).toEqual(expect.arrayContaining(providerPluginIds));
const sortedCompatPluginIds = uniqueSortedStrings(compatPluginIds);
expect(sortedCompatPluginIds).toEqual(manifestProviderPluginIds);
expect(sortedCompatPluginIds).toEqual(expect.arrayContaining(providerPluginIds));
expectPluginAllowlistContains(compatConfig?.plugins?.allow, providerPluginIds, demoAllowEntry);
});

View File

@@ -1,46 +1,16 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { getRegisteredMemoryEmbeddingProvider } from "../memory-embedding-providers.js";
import { createPluginRegistry, type PluginRecord } from "../registry.js";
import type { PluginRuntime } from "../runtime/types.js";
import { createPluginRecord } from "../status.test-helpers.js";
import type { OpenClawPluginApi } from "../types.js";
function registerTestPlugin(params: {
registry: ReturnType<typeof createPluginRegistry>;
config: OpenClawConfig;
record: PluginRecord;
register(api: OpenClawPluginApi): void;
}) {
params.registry.registry.plugins.push(params.record);
params.register(
params.registry.createApi(params.record, {
config: params.config,
}),
);
}
import { createPluginRegistryFixture, registerVirtualTestPlugin } from "./testkit.js";
describe("memory embedding provider registration", () => {
it("only allows memory plugins to register adapters", () => {
const config = {} as OpenClawConfig;
const registry = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
});
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registerVirtualTestPlugin({
registry,
config,
record: createPluginRecord({
id: "not-memory",
name: "Not Memory",
source: "/virtual/not-memory/index.ts",
}),
id: "not-memory",
name: "Not Memory",
register(api) {
api.registerMemoryEmbeddingProvider({
id: "forbidden",
@@ -61,26 +31,14 @@ describe("memory embedding provider registration", () => {
});
it("records the owning memory plugin id for registered adapters", () => {
const config = {} as OpenClawConfig;
const registry = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
});
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registerVirtualTestPlugin({
registry,
config,
record: createPluginRecord({
id: "memory-core",
name: "Memory Core",
kind: "memory",
source: "/virtual/memory-core/index.ts",
}),
id: "memory-core",
name: "Memory Core",
kind: "memory",
register(api) {
api.registerMemoryEmbeddingProvider({
id: "demo-embedding",

View File

@@ -9,77 +9,99 @@ import {
providerContractPluginIds,
speechProviderContractRegistry,
} from "./registry.js";
import { uniqueSortedStrings } from "./testkit.js";
const REGISTRY_CONTRACT_TIMEOUT_MS = 300_000;
describe("plugin contract registry", () => {
function expectUniqueIds(ids: readonly string[]) {
expect(ids).toEqual([...new Set(ids)]);
}
function expectRegistryPluginIds(params: {
actualPluginIds: readonly string[];
predicate: (plugin: {
origin: string;
providers: unknown[];
contracts?: { speechProviders?: unknown[] };
}) => boolean;
}) {
expect(uniqueSortedStrings(params.actualPluginIds)).toEqual(
resolveBundledManifestPluginIds(params.predicate),
);
}
function resolveBundledManifestPluginIds(
predicate: (plugin: {
origin: string;
providers: unknown[];
contracts?: { speechProviders?: unknown[] };
}) => boolean,
) {
return loadPluginManifestRegistry({})
.plugins.filter(predicate)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
it("loads bundled non-provider capability registries without import-time failure", () => {
expect(providerContractLoadError).toBeUndefined();
expect(pluginRegistrationContractRegistry.length).toBeGreaterThan(0);
});
it("does not duplicate bundled provider ids", () => {
const ids = pluginRegistrationContractRegistry.flatMap((entry) => entry.providerIds);
expect(ids).toEqual([...new Set(ids)]);
});
it("does not duplicate bundled web search provider ids", () => {
const ids = pluginRegistrationContractRegistry.flatMap((entry) => entry.webSearchProviderIds);
expect(ids).toEqual([...new Set(ids)]);
it.each([
{
name: "does not duplicate bundled provider ids",
ids: () => pluginRegistrationContractRegistry.flatMap((entry) => entry.providerIds),
},
{
name: "does not duplicate bundled web search provider ids",
ids: () => pluginRegistrationContractRegistry.flatMap((entry) => entry.webSearchProviderIds),
},
{
name: "does not duplicate bundled media provider ids",
ids: () => mediaUnderstandingProviderContractRegistry.map((entry) => entry.provider.id),
},
{
name: "does not duplicate bundled image-generation provider ids",
ids: () => imageGenerationProviderContractRegistry.map((entry) => entry.provider.id),
},
] as const)("$name", ({ ids }) => {
expectUniqueIds(ids());
});
it(
"does not duplicate bundled speech provider ids",
{ timeout: REGISTRY_CONTRACT_TIMEOUT_MS },
() => {
const ids = speechProviderContractRegistry.map((entry) => entry.provider.id);
expect(ids).toEqual([...new Set(ids)]);
expectUniqueIds(speechProviderContractRegistry.map((entry) => entry.provider.id));
},
);
it("does not duplicate bundled media provider ids", () => {
const ids = mediaUnderstandingProviderContractRegistry.map((entry) => entry.provider.id);
expect(ids).toEqual([...new Set(ids)]);
});
it("covers every bundled provider plugin discovered from manifests", () => {
const bundledProviderPluginIds = loadPluginManifestRegistry({})
.plugins.filter((plugin) => plugin.origin === "bundled" && plugin.providers.length > 0)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
expect(providerContractPluginIds).toEqual(bundledProviderPluginIds);
expectRegistryPluginIds({
actualPluginIds: providerContractPluginIds,
predicate: (plugin) => plugin.origin === "bundled" && plugin.providers.length > 0,
});
});
it("covers every bundled speech plugin discovered from manifests", () => {
const bundledSpeechPluginIds = loadPluginManifestRegistry({})
.plugins.filter(
(plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.speechProviders?.length ?? 0) > 0,
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
expect(
[...new Set(speechProviderContractRegistry.map((entry) => entry.pluginId))].toSorted(
(left, right) => left.localeCompare(right),
),
).toEqual(bundledSpeechPluginIds);
expectRegistryPluginIds({
actualPluginIds: speechProviderContractRegistry.map((entry) => entry.pluginId),
predicate: (plugin) =>
plugin.origin === "bundled" && (plugin.contracts?.speechProviders?.length ?? 0) > 0,
});
});
it("covers every bundled web search plugin from the shared resolver", () => {
const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({});
expect(
pluginRegistrationContractRegistry
.filter((entry) => entry.webSearchProviderIds.length > 0)
.map((entry) => entry.pluginId)
.toSorted((left, right) => left.localeCompare(right)),
uniqueSortedStrings(
pluginRegistrationContractRegistry
.filter((entry) => entry.webSearchProviderIds.length > 0)
.map((entry) => entry.pluginId),
),
).toEqual(bundledWebSearchPluginIds);
});
it("does not duplicate bundled image-generation provider ids", () => {
const ids = imageGenerationProviderContractRegistry.map((entry) => entry.provider.id);
expect(ids).toEqual([...new Set(ids)]);
});
});

View File

@@ -1,46 +1,16 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { createPluginRegistry, type PluginRecord } from "../registry.js";
import type { PluginRuntime } from "../runtime/types.js";
import { buildAllPluginInspectReports } from "../status.js";
import { createPluginRecord } from "../status.test-helpers.js";
import type { OpenClawPluginApi } from "../types.js";
function registerTestPlugin(params: {
registry: ReturnType<typeof createPluginRegistry>;
config: OpenClawConfig;
record: PluginRecord;
register(api: OpenClawPluginApi): void;
}) {
params.registry.registry.plugins.push(params.record);
params.register(
params.registry.createApi(params.record, {
config: params.config,
}),
);
}
import { createPluginRegistryFixture, registerVirtualTestPlugin } from "./testkit.js";
describe("plugin shape compatibility matrix", () => {
it("keeps legacy hook-only, plain capability, and hybrid capability shapes explicit", () => {
const config = {} as OpenClawConfig;
const registry = createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
});
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registerVirtualTestPlugin({
registry,
config,
record: createPluginRecord({
id: "lca-legacy",
name: "LCA Legacy",
source: "/virtual/lca-legacy/index.ts",
}),
id: "lca-legacy",
name: "LCA Legacy",
register(api) {
api.on("before_agent_start", () => ({
prependContext: "legacy",
@@ -48,14 +18,11 @@ describe("plugin shape compatibility matrix", () => {
},
});
registerTestPlugin({
registerVirtualTestPlugin({
registry,
config,
record: createPluginRecord({
id: "plain-provider",
name: "Plain Provider",
source: "/virtual/plain-provider/index.ts",
}),
id: "plain-provider",
name: "Plain Provider",
register(api) {
api.registerProvider({
id: "plain-provider",
@@ -65,14 +32,11 @@ describe("plugin shape compatibility matrix", () => {
},
});
registerTestPlugin({
registerVirtualTestPlugin({
registry,
config,
record: createPluginRecord({
id: "hybrid-company",
name: "Hybrid Company",
source: "/virtual/hybrid-company/index.ts",
}),
id: "hybrid-company",
name: "Hybrid Company",
register(api) {
api.registerProvider({
id: "hybrid-company",
@@ -100,14 +64,11 @@ describe("plugin shape compatibility matrix", () => {
},
});
registerTestPlugin({
registerVirtualTestPlugin({
registry,
config,
record: createPluginRecord({
id: "channel-demo",
name: "Channel Demo",
source: "/virtual/channel-demo/index.ts",
}),
id: "channel-demo",
name: "Channel Demo",
register(api) {
api.registerChannel({
plugin: {
@@ -168,11 +129,8 @@ describe("plugin shape compatibility matrix", () => {
]);
expect(inspect[0]?.usesLegacyBeforeAgentStart).toBe(true);
expect(inspect[1]?.capabilities.map((entry) => entry.kind)).toEqual(["text-inference"]);
expect(inspect[2]?.capabilities.map((entry) => entry.kind)).toEqual([
"text-inference",
"web-search",
]);
expect(inspect[3]?.capabilities.map((entry) => entry.kind)).toEqual(["channel"]);
expect(inspect.map((entry) => entry.capabilities.map((capability) => capability.kind))).toEqual(
[[], ["text-inference"], ["text-inference", "web-search"], ["channel"]],
);
});
});

View File

@@ -1,3 +1,9 @@
import type { OpenClawConfig } from "../../config/config.js";
import { createPluginRegistry, type PluginRecord } from "../registry.js";
import type { PluginRuntime } from "../runtime/types.js";
import { createPluginRecord } from "../status.test-helpers.js";
import type { OpenClawPluginApi } from "../types.js";
export {
registerProviderPlugins as registerProviders,
requireRegisteredProvider as requireProvider,
@@ -6,3 +12,54 @@ export {
export function uniqueSortedStrings(values: readonly string[]) {
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
}
export function createPluginRegistryFixture(config = {} as OpenClawConfig) {
return {
config,
registry: createPluginRegistry({
logger: {
info() {},
warn() {},
error() {},
debug() {},
},
runtime: {} as PluginRuntime,
}),
};
}
export function registerTestPlugin(params: {
registry: ReturnType<typeof createPluginRegistry>;
config: OpenClawConfig;
record: PluginRecord;
register(api: OpenClawPluginApi): void;
}) {
params.registry.registry.plugins.push(params.record);
params.register(
params.registry.createApi(params.record, {
config: params.config,
}),
);
}
export function registerVirtualTestPlugin(params: {
registry: ReturnType<typeof createPluginRegistry>;
config: OpenClawConfig;
id: string;
name: string;
source?: string;
kind?: PluginRecord["kind"];
register(this: void, api: OpenClawPluginApi): void;
}) {
registerTestPlugin({
registry: params.registry,
config: params.config,
record: createPluginRecord({
id: params.id,
name: params.name,
source: params.source ?? `/virtual/${params.id}/index.ts`,
...(params.kind ? { kind: params.kind } : {}),
}),
register: params.register,
});
}

View File

@@ -107,6 +107,16 @@ const mockAssistantMessage = (content: AssistantMessage["content"]): AssistantMe
timestamp: Date.now(),
});
function createSummarizeTextDeps() {
return {
completeSimple,
getApiKeyForModel: getApiKeyForModelMock,
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
requireApiKey: requireApiKeyMock,
resolveModelAsync: resolveModelAsyncMock,
};
}
function createOpenAiTelephonyCfg(model: "tts-1" | "gpt-4o-mini-tts"): OpenClawConfig {
return {
messages: {
@@ -127,6 +137,23 @@ function createAudioBuffer(length = 2): Buffer {
return Buffer.from(new Uint8Array(length).fill(1));
}
async function withMockedSpeechFetch(
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<void>,
audioLength: number,
) {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(audioLength),
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
await run(fetchMock);
} finally {
globalThis.fetch = originalFetch;
}
}
function resolveBaseUrl(rawValue: unknown, fallback: string): string {
return typeof rawValue === "string" && rawValue.trim() ? rawValue.replace(/\/+$/u, "") : fallback;
}
@@ -450,30 +477,36 @@ describe("tts", () => {
messages: { tts: {} },
};
async function runSummarizeText(params?: {
text?: string;
targetLength?: number;
cfg?: OpenClawConfig;
}) {
const cfg = params?.cfg ?? baseCfg;
const config = resolveTtsConfig(cfg);
return await summarizeText(
{
text: params?.text ?? "Long text to summarize",
targetLength: params?.targetLength ?? 500,
cfg,
config,
timeoutMs: 30_000,
},
createSummarizeTextDeps(),
);
}
it("summarizes text and returns result with metrics", async () => {
const mockSummary = "This is a summarized version of the text.";
const baseConfig = resolveTtsConfig(baseCfg);
vi.mocked(completeSimple).mockResolvedValue(
mockAssistantMessage([{ type: "text", text: mockSummary }]),
);
const longText = "A".repeat(2000);
const result = await summarizeText(
{
text: longText,
targetLength: 1500,
cfg: baseCfg,
config: baseConfig,
timeoutMs: 30_000,
},
{
completeSimple,
getApiKeyForModel: getApiKeyForModelMock,
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
requireApiKey: requireApiKeyMock,
resolveModelAsync: resolveModelAsyncMock,
},
);
const result = await runSummarizeText({
text: longText,
targetLength: 1500,
});
expect(result.summary).toBe(mockSummary);
expect(result.inputLength).toBe(2000);
@@ -483,23 +516,7 @@ describe("tts", () => {
});
it("calls the summary model with the expected parameters", async () => {
const baseConfig = resolveTtsConfig(baseCfg);
await summarizeText(
{
text: "Long text to summarize",
targetLength: 500,
cfg: baseCfg,
config: baseConfig,
timeoutMs: 30_000,
},
{
completeSimple,
getApiKeyForModel: getApiKeyForModelMock,
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
requireApiKey: requireApiKeyMock,
resolveModelAsync: resolveModelAsyncMock,
},
);
await runSummarizeText();
const callArgs = vi.mocked(completeSimple).mock.calls[0];
expect(callArgs?.[1]?.messages?.[0]?.role).toBe("user");
@@ -513,29 +530,12 @@ describe("tts", () => {
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
messages: { tts: { summaryModel: "openai/gpt-4.1-mini" } },
};
const config = resolveTtsConfig(cfg);
await summarizeText(
{
text: "Long text to summarize",
targetLength: 500,
cfg,
config,
timeoutMs: 30_000,
},
{
completeSimple,
getApiKeyForModel: getApiKeyForModelMock,
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
requireApiKey: requireApiKeyMock,
resolveModelAsync: resolveModelAsyncMock,
},
);
await runSummarizeText({ cfg });
expect(resolveModelAsyncMock).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg);
});
it("keeps the Ollama api for direct summarization", async () => {
const baseConfig = resolveTtsConfig(baseCfg);
vi.mocked(resolveModelAsyncMock).mockResolvedValue({
...createResolvedModel("ollama", "qwen3:8b", "ollama"),
model: {
@@ -544,22 +544,7 @@ describe("tts", () => {
},
} as never);
await summarizeText(
{
text: "Long text to summarize",
targetLength: 500,
cfg: baseCfg,
config: baseConfig,
timeoutMs: 30_000,
},
{
completeSimple,
getApiKeyForModel: getApiKeyForModelMock,
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
requireApiKey: requireApiKeyMock,
resolveModelAsync: resolveModelAsyncMock,
},
);
await runSummarizeText();
expect(vi.mocked(completeSimple).mock.calls[0]?.[0]?.api).toBe("ollama");
expect(ensureCustomApiRegisteredMock).not.toHaveBeenCalled();
@@ -571,23 +556,7 @@ describe("tts", () => {
{ targetLength: 10000, shouldThrow: false },
{ targetLength: 10001, shouldThrow: true },
] as const)("validates targetLength bounds: $targetLength", async (testCase) => {
const baseConfig = resolveTtsConfig(baseCfg);
const call = summarizeText(
{
text: "text",
targetLength: testCase.targetLength,
cfg: baseCfg,
config: baseConfig,
timeoutMs: 30_000,
},
{
completeSimple,
getApiKeyForModel: getApiKeyForModelMock,
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
requireApiKey: requireApiKeyMock,
resolveModelAsync: resolveModelAsyncMock,
},
);
const call = runSummarizeText({ text: "text", targetLength: testCase.targetLength });
if (testCase.shouldThrow) {
await expect(call, String(testCase.targetLength)).rejects.toThrow(
`Invalid targetLength: ${testCase.targetLength}`,
@@ -604,27 +573,10 @@ describe("tts", () => {
message: mockAssistantMessage([{ type: "text", text: " " }]),
},
] as const)("throws when summary output is missing or empty: $name", async (testCase) => {
const baseConfig = resolveTtsConfig(baseCfg);
vi.mocked(completeSimple).mockResolvedValue(testCase.message);
await expect(
summarizeText(
{
text: "text",
targetLength: 500,
cfg: baseCfg,
config: baseConfig,
timeoutMs: 30_000,
},
{
completeSimple,
getApiKeyForModel: getApiKeyForModelMock,
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
requireApiKey: requireApiKeyMock,
resolveModelAsync: resolveModelAsyncMock,
},
),
testCase.name,
).rejects.toThrow("No summary returned");
await expect(runSummarizeText({ text: "text" }), testCase.name).rejects.toThrow(
"No summary returned",
);
});
});
@@ -765,27 +717,11 @@ describe("tts", () => {
});
describe("textToSpeechTelephony openai instructions", () => {
const withMockedTelephonyFetch = async (
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<void>,
) => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(2),
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
await run(fetchMock);
} finally {
globalThis.fetch = originalFetch;
}
};
async function expectTelephonyInstructions(
model: "tts-1" | "gpt-4o-mini-tts",
expectedInstructions: string | undefined,
) {
await withMockedTelephonyFetch(async (fetchMock) => {
await withMockedSpeechFetch(async (fetchMock) => {
const result = await tts.textToSpeechTelephony({
text: "Hello there, friendly caller.",
cfg: createOpenAiTelephonyCfg(model),
@@ -797,7 +733,7 @@ describe("tts", () => {
expect(typeof init.body).toBe("string");
const body = JSON.parse(init.body as string) as Record<string, unknown>;
expect(body.instructions).toBe(expectedInstructions);
});
}, 2);
}
it.each([
@@ -832,16 +768,9 @@ describe("tts", () => {
) => {
const prevPrefs = process.env.OPENCLAW_TTS_PREFS;
process.env.OPENCLAW_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(1),
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
await run(fetchMock);
await withMockedSpeechFetch(run, 1);
} finally {
globalThis.fetch = originalFetch;
process.env.OPENCLAW_TTS_PREFS = prevPrefs;
}
};
@@ -854,6 +783,29 @@ describe("tts", () => {
},
};
async function expectAutoTtsOutcome(params: {
cfg: OpenClawConfig;
payload: { text: string };
inboundAudio?: boolean;
expectedFetchCalls: number;
expectSamePayload: boolean;
}) {
await withMockedAutoTtsFetch(async (fetchMock) => {
const result = await maybeApplyTtsToPayload({
payload: params.payload,
cfg: params.cfg,
kind: "final",
...(params.inboundAudio !== undefined ? { inboundAudio: params.inboundAudio } : {}),
});
expect(fetchMock).toHaveBeenCalledTimes(params.expectedFetchCalls);
if (params.expectSamePayload) {
expect(result).toBe(params.payload);
} else {
expect(result.mediaUrl).toBeDefined();
}
});
}
it.each([
{
name: "inbound gating blocks non-audio",
@@ -879,19 +831,12 @@ describe("tts", () => {
] as const)(
"applies inbound auto-TTS gating by audio status and cleaned text length: $name",
async (testCase) => {
await withMockedAutoTtsFetch(async (fetchMock) => {
const result = await maybeApplyTtsToPayload({
payload: testCase.payload,
cfg: baseCfg,
kind: "final",
inboundAudio: testCase.inboundAudio,
});
expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls);
if (testCase.expectSamePayload) {
expect(result, testCase.name).toBe(testCase.payload);
} else {
expect(result.mediaUrl, testCase.name).toBeDefined();
}
await expectAutoTtsOutcome({
cfg: baseCfg,
payload: testCase.payload,
inboundAudio: testCase.inboundAudio,
expectedFetchCalls: testCase.expectedFetchCalls,
expectSamePayload: testCase.expectSamePayload,
});
},
);
@@ -910,19 +855,11 @@ describe("tts", () => {
expectSamePayload: false,
},
] as const)("respects tagged-mode auto-TTS gating: $name", async (testCase) => {
await withMockedAutoTtsFetch(async (fetchMock) => {
const result = await maybeApplyTtsToPayload({
payload: testCase.payload,
cfg: taggedCfg,
kind: "final",
});
expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls);
if (testCase.expectSamePayload) {
expect(result, testCase.name).toBe(testCase.payload);
} else {
expect(result.mediaUrl, testCase.name).toBeDefined();
}
await expectAutoTtsOutcome({
cfg: taggedCfg,
payload: testCase.payload,
expectedFetchCalls: testCase.expectedFetchCalls,
expectSamePayload: testCase.expectSamePayload,
});
});
});

View File

@@ -101,9 +101,17 @@ const TEST_PROVIDER_IDS = TEST_PROVIDERS.map((provider) => provider.id).toSorted
left.localeCompare(right),
);
function sortedValues(values: readonly string[]) {
return [...values].toSorted((left, right) => left.localeCompare(right));
}
function expectUniqueValues(values: readonly string[]) {
expect(values).toEqual([...new Set(values)]);
}
function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
return providers
.flatMap((provider) => {
return sortedValues(
providers.flatMap((provider) => {
const methodSetups = provider.auth.filter((method) => method.wizard);
if (methodSetups.length > 0) {
return methodSetups.map(
@@ -130,13 +138,13 @@ function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
}
return provider.auth.map((method) => buildProviderPluginMethodChoice(provider.id, method.id));
})
.toSorted((left, right) => left.localeCompare(right));
}),
);
}
function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
return providers
.flatMap((provider) => {
return sortedValues(
providers.flatMap((provider) => {
const modelPicker = provider.wizard?.modelPicker;
if (!modelPicker) {
return [];
@@ -149,8 +157,18 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
return [provider.id];
}
return [buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default")];
})
.toSorted((left, right) => left.localeCompare(right));
}),
);
}
function expectAllChoicesResolve(
values: readonly string[],
resolver: (choice: string) => ReturnType<typeof resolveProviderPluginChoice>,
) {
expect(
values.every((value) => Boolean(resolver(value))),
values.join(", "),
).toBe(true);
}
describe("provider wizard contract", () => {
@@ -173,45 +191,38 @@ describe("provider wizard contract", () => {
env: process.env,
});
expect(
options.map((option) => option.value).toSorted((left, right) => left.localeCompare(right)),
).toEqual(resolveExpectedWizardChoiceValues(TEST_PROVIDERS));
expect(options.map((option) => option.value)).toEqual([
...new Set(options.map((option) => option.value)),
]);
expect(sortedValues(options.map((option) => option.value))).toEqual(
resolveExpectedWizardChoiceValues(TEST_PROVIDERS),
);
expectUniqueValues(options.map((option) => option.value));
});
it("round-trips every shared wizard choice back to its provider and auth method", () => {
const options = resolveProviderWizardOptions({ config: {}, env: process.env });
expect(
options.every((option) => {
const resolved = resolveProviderPluginChoice({
expectAllChoicesResolve(
options.map((option) => option.value),
(choice) =>
resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice: option.value,
});
return Boolean(resolved?.provider.id && resolved?.method.id);
}),
options.map((option) => option.value).join(", "),
).toBe(true);
choice,
}),
);
});
it("exposes every model-picker entry through the shared wizard layer", () => {
const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env });
expect(
entries.map((entry) => entry.value).toSorted((left, right) => left.localeCompare(right)),
).toEqual(resolveExpectedModelPickerValues(TEST_PROVIDERS));
expect(
entries.every((entry) =>
Boolean(
resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice: entry.value,
}),
),
),
entries.map((entry) => entry.value).join(", "),
).toBe(true);
expect(sortedValues(entries.map((entry) => entry.value))).toEqual(
resolveExpectedModelPickerValues(TEST_PROVIDERS),
);
expectAllChoicesResolve(
entries.map((entry) => entry.value),
(choice) =>
resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice,
}),
);
});
});