diff --git a/extensions/amazon-bedrock/provider.contract.test.ts b/extensions/amazon-bedrock/provider.contract.test.ts new file mode 100644 index 00000000000..8535108143b --- /dev/null +++ b/extensions/amazon-bedrock/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("amazon-bedrock"); diff --git a/extensions/anthropic/plugin-registration.contract.test.ts b/extensions/anthropic/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..5d526538798 --- /dev/null +++ b/extensions/anthropic/plugin-registration.contract.test.ts @@ -0,0 +1,9 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "anthropic", + providerIds: ["anthropic"], + mediaUnderstandingProviderIds: ["anthropic"], + cliBackendIds: ["claude-cli"], + requireDescribeImages: true, +}); diff --git a/extensions/anthropic/provider-runtime.contract.test.ts b/extensions/anthropic/provider-runtime.contract.test.ts new file mode 100644 index 00000000000..9c67fbb6561 --- /dev/null +++ b/extensions/anthropic/provider-runtime.contract.test.ts @@ -0,0 +1,3 @@ +import { describeAnthropicProviderRuntimeContract } from "../../test/helpers/extensions/provider-runtime-contract.js"; + +describeAnthropicProviderRuntimeContract(); diff --git a/extensions/anthropic/provider.contract.test.ts b/extensions/anthropic/provider.contract.test.ts new file mode 100644 index 00000000000..da253adb244 --- /dev/null +++ b/extensions/anthropic/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("anthropic"); diff --git a/extensions/bluebubbles/package-manifest.contract.test.ts b/extensions/bluebubbles/package-manifest.contract.test.ts new file mode 100644 index 00000000000..12fc9bf7eac --- /dev/null +++ b/extensions/bluebubbles/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "bluebubbles", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/bluebubbles/src/dm-policy.contract.test.ts b/extensions/bluebubbles/src/dm-policy.contract.test.ts new file mode 100644 index 00000000000..b1a89ae0c0f --- /dev/null +++ b/extensions/bluebubbles/src/dm-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installDmPolicyContractSuite } from "../../../test/helpers/channels/dm-policy-contract.js"; + +describe("bluebubbles dm policy contract", () => { + installDmPolicyContractSuite("bluebubbles"); +}); diff --git a/extensions/bluebubbles/src/registry-backed.contract.test.ts b/extensions/bluebubbles/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..0f7a29d5c57 --- /dev/null +++ b/extensions/bluebubbles/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("bluebubbles"); diff --git a/extensions/brave/bundled-web-search.contract.test.ts b/extensions/brave/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..710beee50b2 --- /dev/null +++ b/extensions/brave/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("brave"); diff --git a/extensions/brave/plugin-registration.contract.test.ts b/extensions/brave/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..ae7408ad619 --- /dev/null +++ b/extensions/brave/plugin-registration.contract.test.ts @@ -0,0 +1,6 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "brave", + webSearchProviderIds: ["brave"], +}); diff --git a/extensions/brave/web-search-provider.contract.test.ts b/extensions/brave/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..759a197d079 --- /dev/null +++ b/extensions/brave/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("brave"); diff --git a/extensions/byteplus/provider.contract.test.ts b/extensions/byteplus/provider.contract.test.ts new file mode 100644 index 00000000000..7a18ee2c159 --- /dev/null +++ b/extensions/byteplus/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("byteplus"); diff --git a/extensions/chutes/provider.contract.test.ts b/extensions/chutes/provider.contract.test.ts new file mode 100644 index 00000000000..6ecced04b87 --- /dev/null +++ b/extensions/chutes/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("chutes"); diff --git a/extensions/cloudflare-ai-gateway/provider-discovery.contract.test.ts b/extensions/cloudflare-ai-gateway/provider-discovery.contract.test.ts new file mode 100644 index 00000000000..f6c4afe2ae1 --- /dev/null +++ b/extensions/cloudflare-ai-gateway/provider-discovery.contract.test.ts @@ -0,0 +1,3 @@ +import { describeCloudflareAiGatewayProviderDiscoveryContract } from "../../test/helpers/extensions/provider-discovery-contract.js"; + +describeCloudflareAiGatewayProviderDiscoveryContract(); diff --git a/extensions/cloudflare-ai-gateway/provider.contract.test.ts b/extensions/cloudflare-ai-gateway/provider.contract.test.ts new file mode 100644 index 00000000000..b0aa83bcf5c --- /dev/null +++ b/extensions/cloudflare-ai-gateway/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("cloudflare-ai-gateway"); diff --git a/extensions/copilot-proxy/provider.contract.test.ts b/extensions/copilot-proxy/provider.contract.test.ts new file mode 100644 index 00000000000..2d94ce8e85d --- /dev/null +++ b/extensions/copilot-proxy/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("copilot-proxy"); diff --git a/extensions/deepgram/plugin-registration.contract.test.ts b/extensions/deepgram/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..3c541d8a334 --- /dev/null +++ b/extensions/deepgram/plugin-registration.contract.test.ts @@ -0,0 +1,6 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "deepgram", + mediaUnderstandingProviderIds: ["deepgram"], +}); diff --git a/extensions/deepseek/provider.contract.test.ts b/extensions/deepseek/provider.contract.test.ts new file mode 100644 index 00000000000..750deed15c7 --- /dev/null +++ b/extensions/deepseek/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("deepseek"); diff --git a/extensions/discord/package-manifest.contract.test.ts b/extensions/discord/package-manifest.contract.test.ts new file mode 100644 index 00000000000..be1d32becdb --- /dev/null +++ b/extensions/discord/package-manifest.contract.test.ts @@ -0,0 +1,7 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "discord", + runtimeDeps: ["@buape/carbon", "https-proxy-agent"], + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/discord/src/group-policy.contract.test.ts b/extensions/discord/src/group-policy.contract.test.ts new file mode 100644 index 00000000000..c1cd467d76b --- /dev/null +++ b/extensions/discord/src/group-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installDiscordGroupPolicyContractSuite } from "../../../test/helpers/channels/group-policy-contract.js"; + +describe("discord group policy contract", () => { + installDiscordGroupPolicyContractSuite(); +}); diff --git a/extensions/discord/src/inbound.contract.test.ts b/extensions/discord/src/inbound.contract.test.ts new file mode 100644 index 00000000000..5e3807438ab --- /dev/null +++ b/extensions/discord/src/inbound.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installDiscordInboundContractSuite } from "../../../test/helpers/channels/inbound-contract.js"; + +describe("discord inbound contract", () => { + installDiscordInboundContractSuite(); +}); diff --git a/extensions/discord/src/outbound-payload.contract.test.ts b/extensions/discord/src/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..ffe7cc3b1d5 --- /dev/null +++ b/extensions/discord/src/outbound-payload.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installDiscordOutboundPayloadContractSuite } from "../../../test/helpers/channels/outbound-payload-contract.js"; + +describe("discord outbound payload contract", () => { + installDiscordOutboundPayloadContractSuite(); +}); diff --git a/extensions/discord/src/plugins-core.contract.test.ts b/extensions/discord/src/plugins-core.contract.test.ts new file mode 100644 index 00000000000..a072292f15c --- /dev/null +++ b/extensions/discord/src/plugins-core.contract.test.ts @@ -0,0 +1,3 @@ +import { describeDiscordPluginsCoreExtensionContract } from "../../../test/helpers/channels/plugins-core-extension-contract.js"; + +describeDiscordPluginsCoreExtensionContract(); diff --git a/extensions/discord/src/registry-backed.contract.test.ts b/extensions/discord/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..fc9cdb52ced --- /dev/null +++ b/extensions/discord/src/registry-backed.contract.test.ts @@ -0,0 +1,7 @@ +import { + describeChannelRegistryBackedContracts, + describeSessionBindingRegistryBackedContract, +} from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("discord"); +describeSessionBindingRegistryBackedContract("discord"); diff --git a/extensions/duckduckgo/bundled-web-search.contract.test.ts b/extensions/duckduckgo/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..66102c8e556 --- /dev/null +++ b/extensions/duckduckgo/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("duckduckgo"); diff --git a/extensions/duckduckgo/plugin-registration.contract.test.ts b/extensions/duckduckgo/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..23ba150fbb1 --- /dev/null +++ b/extensions/duckduckgo/plugin-registration.contract.test.ts @@ -0,0 +1,6 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "duckduckgo", + webSearchProviderIds: ["duckduckgo"], +}); diff --git a/extensions/duckduckgo/web-search-provider.contract.test.ts b/extensions/duckduckgo/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..8e54ab2c5a4 --- /dev/null +++ b/extensions/duckduckgo/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("duckduckgo"); diff --git a/extensions/elevenlabs/plugin-registration.contract.test.ts b/extensions/elevenlabs/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..7464c71f847 --- /dev/null +++ b/extensions/elevenlabs/plugin-registration.contract.test.ts @@ -0,0 +1,7 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "elevenlabs", + speechProviderIds: ["elevenlabs"], + requireSpeechVoices: true, +}); diff --git a/extensions/exa/bundled-web-search.contract.test.ts b/extensions/exa/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..a6dafde9876 --- /dev/null +++ b/extensions/exa/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("exa"); diff --git a/extensions/exa/plugin-registration.contract.test.ts b/extensions/exa/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..60c67675442 --- /dev/null +++ b/extensions/exa/plugin-registration.contract.test.ts @@ -0,0 +1,6 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "exa", + webSearchProviderIds: ["exa"], +}); diff --git a/extensions/exa/web-search-provider.contract.test.ts b/extensions/exa/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..65a4aed16ad --- /dev/null +++ b/extensions/exa/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("exa"); diff --git a/extensions/fal/plugin-registration.contract.test.ts b/extensions/fal/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..1721f0e457b --- /dev/null +++ b/extensions/fal/plugin-registration.contract.test.ts @@ -0,0 +1,7 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "fal", + providerIds: ["fal"], + imageGenerationProviderIds: ["fal"], +}); diff --git a/extensions/fal/provider.contract.test.ts b/extensions/fal/provider.contract.test.ts new file mode 100644 index 00000000000..09f7829b8f3 --- /dev/null +++ b/extensions/fal/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("fal"); diff --git a/extensions/feishu/package-manifest.contract.test.ts b/extensions/feishu/package-manifest.contract.test.ts new file mode 100644 index 00000000000..8239226d7e6 --- /dev/null +++ b/extensions/feishu/package-manifest.contract.test.ts @@ -0,0 +1,7 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "feishu", + runtimeDeps: ["@larksuiteoapi/node-sdk"], + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/feishu/src/registry-backed.contract.test.ts b/extensions/feishu/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..5c1958a145c --- /dev/null +++ b/extensions/feishu/src/registry-backed.contract.test.ts @@ -0,0 +1,7 @@ +import { + describeChannelRegistryBackedContracts, + describeSessionBindingRegistryBackedContract, +} from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("feishu"); +describeSessionBindingRegistryBackedContract("feishu"); diff --git a/extensions/feishu/src/session-binding.contract.test.ts b/extensions/feishu/src/session-binding.contract.test.ts new file mode 100644 index 00000000000..17ba49a9b23 --- /dev/null +++ b/extensions/feishu/src/session-binding.contract.test.ts @@ -0,0 +1,4 @@ +import { describeSessionBindingContractCoverage } from "../../../test/helpers/channels/session-binding-contract.js"; +import { feishuSessionBindingAdapterChannels } from "../api.js"; + +describeSessionBindingContractCoverage(feishuSessionBindingAdapterChannels); diff --git a/extensions/firecrawl/bundled-web-search.contract.test.ts b/extensions/firecrawl/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..8c97d44304c --- /dev/null +++ b/extensions/firecrawl/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("firecrawl"); diff --git a/extensions/firecrawl/plugin-registration.contract.test.ts b/extensions/firecrawl/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..2e2c225cf54 --- /dev/null +++ b/extensions/firecrawl/plugin-registration.contract.test.ts @@ -0,0 +1,7 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "firecrawl", + webSearchProviderIds: ["firecrawl"], + toolNames: ["firecrawl_search", "firecrawl_scrape"], +}); diff --git a/extensions/firecrawl/web-search-provider.contract.test.ts b/extensions/firecrawl/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..8f4bd036afd --- /dev/null +++ b/extensions/firecrawl/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("firecrawl"); diff --git a/extensions/github-copilot/provider-auth.contract.test.ts b/extensions/github-copilot/provider-auth.contract.test.ts new file mode 100644 index 00000000000..fcbc7e82111 --- /dev/null +++ b/extensions/github-copilot/provider-auth.contract.test.ts @@ -0,0 +1,3 @@ +import { describeGithubCopilotProviderAuthContract } from "../../test/helpers/extensions/provider-auth-contract.js"; + +describeGithubCopilotProviderAuthContract(); diff --git a/extensions/github-copilot/provider-discovery.contract.test.ts b/extensions/github-copilot/provider-discovery.contract.test.ts new file mode 100644 index 00000000000..e681fe0befe --- /dev/null +++ b/extensions/github-copilot/provider-discovery.contract.test.ts @@ -0,0 +1,3 @@ +import { describeGithubCopilotProviderDiscoveryContract } from "../../test/helpers/extensions/provider-discovery-contract.js"; + +describeGithubCopilotProviderDiscoveryContract(); diff --git a/extensions/github-copilot/provider-runtime.contract.test.ts b/extensions/github-copilot/provider-runtime.contract.test.ts new file mode 100644 index 00000000000..5014b4b5d30 --- /dev/null +++ b/extensions/github-copilot/provider-runtime.contract.test.ts @@ -0,0 +1,3 @@ +import { describeGithubCopilotProviderRuntimeContract } from "../../test/helpers/extensions/provider-runtime-contract.js"; + +describeGithubCopilotProviderRuntimeContract(); diff --git a/extensions/github-copilot/provider.contract.test.ts b/extensions/github-copilot/provider.contract.test.ts new file mode 100644 index 00000000000..54b89e0a296 --- /dev/null +++ b/extensions/github-copilot/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("github-copilot"); diff --git a/extensions/google/bundled-web-search.contract.test.ts b/extensions/google/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..6e39bcc5606 --- /dev/null +++ b/extensions/google/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("google"); diff --git a/extensions/google/plugin-registration.contract.test.ts b/extensions/google/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..25510d2a782 --- /dev/null +++ b/extensions/google/plugin-registration.contract.test.ts @@ -0,0 +1,12 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "google", + providerIds: ["google", "google-gemini-cli"], + webSearchProviderIds: ["gemini"], + mediaUnderstandingProviderIds: ["google"], + imageGenerationProviderIds: ["google"], + cliBackendIds: ["google-gemini-cli"], + requireDescribeImages: true, + requireGenerateImage: true, +}); diff --git a/extensions/google/provider-runtime.contract.test.ts b/extensions/google/provider-runtime.contract.test.ts new file mode 100644 index 00000000000..f981a75c1d3 --- /dev/null +++ b/extensions/google/provider-runtime.contract.test.ts @@ -0,0 +1,3 @@ +import { describeGoogleProviderRuntimeContract } from "../../test/helpers/extensions/provider-runtime-contract.js"; + +describeGoogleProviderRuntimeContract(); diff --git a/extensions/google/provider.contract.test.ts b/extensions/google/provider.contract.test.ts new file mode 100644 index 00000000000..5d2e92e748c --- /dev/null +++ b/extensions/google/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("google"); diff --git a/extensions/google/web-search-provider.contract.test.ts b/extensions/google/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..311f50a3e41 --- /dev/null +++ b/extensions/google/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("google"); diff --git a/extensions/googlechat/package-manifest.contract.test.ts b/extensions/googlechat/package-manifest.contract.test.ts new file mode 100644 index 00000000000..14224ffb7ca --- /dev/null +++ b/extensions/googlechat/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "googlechat", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/groq/plugin-registration.contract.test.ts b/extensions/groq/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..ed05341a7e2 --- /dev/null +++ b/extensions/groq/plugin-registration.contract.test.ts @@ -0,0 +1,6 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "groq", + mediaUnderstandingProviderIds: ["groq"], +}); diff --git a/extensions/huggingface/provider.contract.test.ts b/extensions/huggingface/provider.contract.test.ts new file mode 100644 index 00000000000..4bbb0c0d92b --- /dev/null +++ b/extensions/huggingface/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("huggingface"); diff --git a/extensions/imessage/src/group-policy.contract.test.ts b/extensions/imessage/src/group-policy.contract.test.ts new file mode 100644 index 00000000000..83654f12f91 --- /dev/null +++ b/extensions/imessage/src/group-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installIMessageGroupPolicyContractSuite } from "../../../test/helpers/channels/group-policy-contract.js"; + +describe("imessage group policy contract", () => { + installIMessageGroupPolicyContractSuite(); +}); diff --git a/extensions/imessage/src/outbound-payload.contract.test.ts b/extensions/imessage/src/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..bd40c15df99 --- /dev/null +++ b/extensions/imessage/src/outbound-payload.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installDirectTextMediaOutboundPayloadContractSuite } from "../../../test/helpers/channels/outbound-payload-contract.js"; + +describe("imessage outbound payload contract", () => { + installDirectTextMediaOutboundPayloadContractSuite(); +}); diff --git a/extensions/imessage/src/plugins-core.contract.test.ts b/extensions/imessage/src/plugins-core.contract.test.ts new file mode 100644 index 00000000000..116d67d80d7 --- /dev/null +++ b/extensions/imessage/src/plugins-core.contract.test.ts @@ -0,0 +1,3 @@ +import { describeIMessagePluginsCoreExtensionContract } from "../../../test/helpers/channels/plugins-core-extension-contract.js"; + +describeIMessagePluginsCoreExtensionContract(); diff --git a/extensions/imessage/src/registry-backed.contract.test.ts b/extensions/imessage/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..8e29b07941e --- /dev/null +++ b/extensions/imessage/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("imessage"); diff --git a/extensions/irc/package-manifest.contract.test.ts b/extensions/irc/package-manifest.contract.test.ts new file mode 100644 index 00000000000..492e009c7b3 --- /dev/null +++ b/extensions/irc/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "irc", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/irc/src/registry-backed.contract.test.ts b/extensions/irc/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..4d9593d6a82 --- /dev/null +++ b/extensions/irc/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("irc"); diff --git a/extensions/kilocode/provider.contract.test.ts b/extensions/kilocode/provider.contract.test.ts new file mode 100644 index 00000000000..a42c9002a46 --- /dev/null +++ b/extensions/kilocode/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("kilocode"); diff --git a/extensions/line/package-manifest.contract.test.ts b/extensions/line/package-manifest.contract.test.ts new file mode 100644 index 00000000000..03c7fcfbd2b --- /dev/null +++ b/extensions/line/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "line", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/line/src/plugins-core.contract.test.ts b/extensions/line/src/plugins-core.contract.test.ts new file mode 100644 index 00000000000..480be6f35e5 --- /dev/null +++ b/extensions/line/src/plugins-core.contract.test.ts @@ -0,0 +1,3 @@ +import { describeLinePluginsCoreExtensionContract } from "../../../test/helpers/channels/plugins-core-extension-contract.js"; + +describeLinePluginsCoreExtensionContract(); diff --git a/extensions/line/src/registry-backed.contract.test.ts b/extensions/line/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..9646ca50daf --- /dev/null +++ b/extensions/line/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("line"); diff --git a/extensions/litellm/provider.contract.test.ts b/extensions/litellm/provider.contract.test.ts new file mode 100644 index 00000000000..cad6aa56eb0 --- /dev/null +++ b/extensions/litellm/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("litellm"); diff --git a/extensions/matrix/package-manifest.contract.test.ts b/extensions/matrix/package-manifest.contract.test.ts new file mode 100644 index 00000000000..e4a92c61229 --- /dev/null +++ b/extensions/matrix/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "matrix", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/matrix/src/registry-backed.contract.test.ts b/extensions/matrix/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..7cf28e5d074 --- /dev/null +++ b/extensions/matrix/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeSessionBindingRegistryBackedContract } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeSessionBindingRegistryBackedContract("matrix"); diff --git a/extensions/matrix/src/session-binding.contract.test.ts b/extensions/matrix/src/session-binding.contract.test.ts new file mode 100644 index 00000000000..f407c8bed68 --- /dev/null +++ b/extensions/matrix/src/session-binding.contract.test.ts @@ -0,0 +1,4 @@ +import { describeSessionBindingContractCoverage } from "../../../test/helpers/channels/session-binding-contract.js"; +import { matrixSessionBindingAdapterChannels } from "../api.js"; + +describeSessionBindingContractCoverage(matrixSessionBindingAdapterChannels); diff --git a/extensions/mattermost/package-manifest.contract.test.ts b/extensions/mattermost/package-manifest.contract.test.ts new file mode 100644 index 00000000000..41d6aede6be --- /dev/null +++ b/extensions/mattermost/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "mattermost", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/mattermost/src/dm-policy.contract.test.ts b/extensions/mattermost/src/dm-policy.contract.test.ts new file mode 100644 index 00000000000..fa705383a0b --- /dev/null +++ b/extensions/mattermost/src/dm-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installDmPolicyContractSuite } from "../../../test/helpers/channels/dm-policy-contract.js"; + +describe("mattermost dm policy contract", () => { + installDmPolicyContractSuite("mattermost"); +}); diff --git a/extensions/mattermost/src/registry-backed.contract.test.ts b/extensions/mattermost/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..09ff5708113 --- /dev/null +++ b/extensions/mattermost/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("mattermost"); diff --git a/extensions/memory-lancedb/package-manifest.contract.test.ts b/extensions/memory-lancedb/package-manifest.contract.test.ts new file mode 100644 index 00000000000..15d6d2a5d4a --- /dev/null +++ b/extensions/memory-lancedb/package-manifest.contract.test.ts @@ -0,0 +1,7 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "memory-lancedb", + runtimeDeps: ["@lancedb/lancedb"], + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/microsoft-foundry/provider.contract.test.ts b/extensions/microsoft-foundry/provider.contract.test.ts new file mode 100644 index 00000000000..5c1c4495444 --- /dev/null +++ b/extensions/microsoft-foundry/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("microsoft-foundry"); diff --git a/extensions/microsoft/plugin-registration.contract.test.ts b/extensions/microsoft/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..995e159ed8b --- /dev/null +++ b/extensions/microsoft/plugin-registration.contract.test.ts @@ -0,0 +1,7 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "microsoft", + speechProviderIds: ["microsoft"], + requireSpeechVoices: true, +}); diff --git a/extensions/minimax/plugin-registration.contract.test.ts b/extensions/minimax/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..a8d659c3762 --- /dev/null +++ b/extensions/minimax/plugin-registration.contract.test.ts @@ -0,0 +1,10 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "minimax", + providerIds: ["minimax", "minimax-portal"], + mediaUnderstandingProviderIds: ["minimax", "minimax-portal"], + imageGenerationProviderIds: ["minimax", "minimax-portal"], + requireDescribeImages: true, + requireGenerateImage: true, +}); diff --git a/extensions/minimax/provider-discovery.contract.test.ts b/extensions/minimax/provider-discovery.contract.test.ts new file mode 100644 index 00000000000..c1f6e2d5609 --- /dev/null +++ b/extensions/minimax/provider-discovery.contract.test.ts @@ -0,0 +1,3 @@ +import { describeMinimaxProviderDiscoveryContract } from "../../test/helpers/extensions/provider-discovery-contract.js"; + +describeMinimaxProviderDiscoveryContract(); diff --git a/extensions/minimax/provider.contract.test.ts b/extensions/minimax/provider.contract.test.ts new file mode 100644 index 00000000000..e8dfee30a32 --- /dev/null +++ b/extensions/minimax/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("minimax"); diff --git a/extensions/mistral/plugin-registration.contract.test.ts b/extensions/mistral/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..ae1184ae860 --- /dev/null +++ b/extensions/mistral/plugin-registration.contract.test.ts @@ -0,0 +1,6 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "mistral", + mediaUnderstandingProviderIds: ["mistral"], +}); diff --git a/extensions/mistral/provider.contract.test.ts b/extensions/mistral/provider.contract.test.ts new file mode 100644 index 00000000000..d72c32b076e --- /dev/null +++ b/extensions/mistral/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("mistral"); diff --git a/extensions/modelstudio/provider-discovery.contract.test.ts b/extensions/modelstudio/provider-discovery.contract.test.ts new file mode 100644 index 00000000000..da900d6d514 --- /dev/null +++ b/extensions/modelstudio/provider-discovery.contract.test.ts @@ -0,0 +1,3 @@ +import { describeModelStudioProviderDiscoveryContract } from "../../test/helpers/extensions/provider-discovery-contract.js"; + +describeModelStudioProviderDiscoveryContract(); diff --git a/extensions/modelstudio/provider.contract.test.ts b/extensions/modelstudio/provider.contract.test.ts new file mode 100644 index 00000000000..4da569bd8f9 --- /dev/null +++ b/extensions/modelstudio/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("modelstudio"); diff --git a/extensions/moonshot/bundled-web-search.contract.test.ts b/extensions/moonshot/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..3710d1863b5 --- /dev/null +++ b/extensions/moonshot/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("moonshot"); diff --git a/extensions/moonshot/plugin-registration.contract.test.ts b/extensions/moonshot/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..8f4d8aa09fc --- /dev/null +++ b/extensions/moonshot/plugin-registration.contract.test.ts @@ -0,0 +1,17 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "moonshot", + providerIds: ["moonshot"], + webSearchProviderIds: ["kimi"], + mediaUnderstandingProviderIds: ["moonshot"], + requireDescribeImages: true, + manifestAuthChoice: { + pluginId: "kimi", + choiceId: "kimi-code-api-key", + choiceLabel: "Kimi Code API key (subscription)", + groupId: "moonshot", + groupLabel: "Moonshot AI (Kimi K2.5)", + groupHint: "Kimi K2.5", + }, +}); diff --git a/extensions/moonshot/provider.contract.test.ts b/extensions/moonshot/provider.contract.test.ts new file mode 100644 index 00000000000..6b92074f8e1 --- /dev/null +++ b/extensions/moonshot/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("moonshot"); diff --git a/extensions/moonshot/web-search-provider.contract.test.ts b/extensions/moonshot/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..eff40a5fcb2 --- /dev/null +++ b/extensions/moonshot/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("moonshot"); diff --git a/extensions/msteams/channel-catalog.contract.test.ts b/extensions/msteams/channel-catalog.contract.test.ts new file mode 100644 index 00000000000..fca64a1d892 --- /dev/null +++ b/extensions/msteams/channel-catalog.contract.test.ts @@ -0,0 +1,7 @@ +import { describeChannelCatalogEntryContract } from "../../test/helpers/channels/channel-catalog-contract.js"; + +describeChannelCatalogEntryContract({ + channelId: "msteams", + npmSpec: "@openclaw/msteams", + alias: "teams", +}); diff --git a/extensions/msteams/package-manifest.contract.test.ts b/extensions/msteams/package-manifest.contract.test.ts new file mode 100644 index 00000000000..52ca75461dc --- /dev/null +++ b/extensions/msteams/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "msteams", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/nextcloud-talk/package-manifest.contract.test.ts b/extensions/nextcloud-talk/package-manifest.contract.test.ts new file mode 100644 index 00000000000..eb5aa782e7c --- /dev/null +++ b/extensions/nextcloud-talk/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "nextcloud-talk", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/nextcloud-talk/src/registry-backed.contract.test.ts b/extensions/nextcloud-talk/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..19f6b12e743 --- /dev/null +++ b/extensions/nextcloud-talk/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("nextcloud-talk"); diff --git a/extensions/nostr/package-manifest.contract.test.ts b/extensions/nostr/package-manifest.contract.test.ts new file mode 100644 index 00000000000..4b47bf908c8 --- /dev/null +++ b/extensions/nostr/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "nostr", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/nvidia/provider.contract.test.ts b/extensions/nvidia/provider.contract.test.ts new file mode 100644 index 00000000000..3fac55560a6 --- /dev/null +++ b/extensions/nvidia/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("nvidia"); diff --git a/extensions/ollama/provider-discovery.contract.test.ts b/extensions/ollama/provider-discovery.contract.test.ts new file mode 100644 index 00000000000..ddbbd4d089d --- /dev/null +++ b/extensions/ollama/provider-discovery.contract.test.ts @@ -0,0 +1,3 @@ +import { describeOllamaProviderDiscoveryContract } from "../../test/helpers/extensions/provider-discovery-contract.js"; + +describeOllamaProviderDiscoveryContract(); diff --git a/extensions/ollama/provider.contract.test.ts b/extensions/ollama/provider.contract.test.ts new file mode 100644 index 00000000000..16908e6c086 --- /dev/null +++ b/extensions/ollama/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("ollama"); diff --git a/extensions/openai/plugin-registration.contract.test.ts b/extensions/openai/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..10e62535d1c --- /dev/null +++ b/extensions/openai/plugin-registration.contract.test.ts @@ -0,0 +1,13 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "openai", + providerIds: ["openai", "openai-codex"], + speechProviderIds: ["openai"], + mediaUnderstandingProviderIds: ["openai", "openai-codex"], + imageGenerationProviderIds: ["openai"], + cliBackendIds: ["codex-cli"], + requireSpeechVoices: true, + requireDescribeImages: true, + requireGenerateImage: true, +}); diff --git a/extensions/openai/provider-auth.contract.test.ts b/extensions/openai/provider-auth.contract.test.ts new file mode 100644 index 00000000000..7956297223a --- /dev/null +++ b/extensions/openai/provider-auth.contract.test.ts @@ -0,0 +1,3 @@ +import { describeOpenAICodexProviderAuthContract } from "../../test/helpers/extensions/provider-auth-contract.js"; + +describeOpenAICodexProviderAuthContract(); diff --git a/extensions/openai/provider-catalog.contract.test.ts b/extensions/openai/provider-catalog.contract.test.ts new file mode 100644 index 00000000000..26269eaaf7f --- /dev/null +++ b/extensions/openai/provider-catalog.contract.test.ts @@ -0,0 +1,3 @@ +import { describeOpenAIProviderCatalogContract } from "../../test/helpers/extensions/provider-catalog-contract.js"; + +describeOpenAIProviderCatalogContract(); diff --git a/extensions/openai/provider-runtime.contract.test.ts b/extensions/openai/provider-runtime.contract.test.ts new file mode 100644 index 00000000000..6a52f4626d5 --- /dev/null +++ b/extensions/openai/provider-runtime.contract.test.ts @@ -0,0 +1,3 @@ +import { describeOpenAIProviderRuntimeContract } from "../../test/helpers/extensions/provider-runtime-contract.js"; + +describeOpenAIProviderRuntimeContract(); diff --git a/extensions/openai/provider.contract.test.ts b/extensions/openai/provider.contract.test.ts new file mode 100644 index 00000000000..1da70496c2f --- /dev/null +++ b/extensions/openai/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("openai"); diff --git a/extensions/opencode-go/provider.contract.test.ts b/extensions/opencode-go/provider.contract.test.ts new file mode 100644 index 00000000000..88d7ea494b9 --- /dev/null +++ b/extensions/opencode-go/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("opencode-go"); diff --git a/extensions/opencode/provider.contract.test.ts b/extensions/opencode/provider.contract.test.ts new file mode 100644 index 00000000000..d0663081b41 --- /dev/null +++ b/extensions/opencode/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("opencode"); diff --git a/extensions/openrouter/plugin-registration.contract.test.ts b/extensions/openrouter/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..029ea7a91d2 --- /dev/null +++ b/extensions/openrouter/plugin-registration.contract.test.ts @@ -0,0 +1,8 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "openrouter", + providerIds: ["openrouter"], + mediaUnderstandingProviderIds: ["openrouter"], + requireDescribeImages: true, +}); diff --git a/extensions/openrouter/provider-runtime.contract.test.ts b/extensions/openrouter/provider-runtime.contract.test.ts new file mode 100644 index 00000000000..20bd4789c78 --- /dev/null +++ b/extensions/openrouter/provider-runtime.contract.test.ts @@ -0,0 +1,3 @@ +import { describeOpenRouterProviderRuntimeContract } from "../../test/helpers/extensions/provider-runtime-contract.js"; + +describeOpenRouterProviderRuntimeContract(); diff --git a/extensions/openrouter/provider.contract.test.ts b/extensions/openrouter/provider.contract.test.ts new file mode 100644 index 00000000000..8aad4caf208 --- /dev/null +++ b/extensions/openrouter/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("openrouter"); diff --git a/extensions/perplexity/bundled-web-search.contract.test.ts b/extensions/perplexity/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..b6d4f3e6842 --- /dev/null +++ b/extensions/perplexity/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("perplexity"); diff --git a/extensions/perplexity/plugin-registration.contract.test.ts b/extensions/perplexity/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..50fb424537a --- /dev/null +++ b/extensions/perplexity/plugin-registration.contract.test.ts @@ -0,0 +1,6 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "perplexity", + webSearchProviderIds: ["perplexity"], +}); diff --git a/extensions/perplexity/web-search-provider.contract.test.ts b/extensions/perplexity/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..8616f446d19 --- /dev/null +++ b/extensions/perplexity/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("perplexity"); diff --git a/extensions/qianfan/provider.contract.test.ts b/extensions/qianfan/provider.contract.test.ts new file mode 100644 index 00000000000..1e64f6d5b1d --- /dev/null +++ b/extensions/qianfan/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("qianfan"); diff --git a/extensions/sglang/provider-discovery.contract.test.ts b/extensions/sglang/provider-discovery.contract.test.ts new file mode 100644 index 00000000000..c6521b125b9 --- /dev/null +++ b/extensions/sglang/provider-discovery.contract.test.ts @@ -0,0 +1,3 @@ +import { describeSglangProviderDiscoveryContract } from "../../test/helpers/extensions/provider-discovery-contract.js"; + +describeSglangProviderDiscoveryContract(); diff --git a/extensions/sglang/provider.contract.test.ts b/extensions/sglang/provider.contract.test.ts new file mode 100644 index 00000000000..fcffdba5631 --- /dev/null +++ b/extensions/sglang/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("sglang"); diff --git a/extensions/signal/src/dm-policy.contract.test.ts b/extensions/signal/src/dm-policy.contract.test.ts new file mode 100644 index 00000000000..31990923418 --- /dev/null +++ b/extensions/signal/src/dm-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installDmPolicyContractSuite } from "../../../test/helpers/channels/dm-policy-contract.js"; + +describe("signal dm policy contract", () => { + installDmPolicyContractSuite("signal"); +}); diff --git a/extensions/signal/src/inbound.contract.test.ts b/extensions/signal/src/inbound.contract.test.ts new file mode 100644 index 00000000000..9f4d71f1bcd --- /dev/null +++ b/extensions/signal/src/inbound.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installSignalInboundContractSuite } from "../../../test/helpers/channels/inbound-contract.js"; + +describe("signal inbound contract", () => { + installSignalInboundContractSuite(); +}); diff --git a/extensions/signal/src/plugins-core.contract.test.ts b/extensions/signal/src/plugins-core.contract.test.ts new file mode 100644 index 00000000000..a6cc40fe2a2 --- /dev/null +++ b/extensions/signal/src/plugins-core.contract.test.ts @@ -0,0 +1,3 @@ +import { describeSignalPluginsCoreExtensionContract } from "../../../test/helpers/channels/plugins-core-extension-contract.js"; + +describeSignalPluginsCoreExtensionContract(); diff --git a/extensions/signal/src/registry-backed.contract.test.ts b/extensions/signal/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..605ed7322c2 --- /dev/null +++ b/extensions/signal/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("signal"); diff --git a/extensions/slack/package-manifest.contract.test.ts b/extensions/slack/package-manifest.contract.test.ts new file mode 100644 index 00000000000..5f44025fab4 --- /dev/null +++ b/extensions/slack/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "slack", + runtimeDeps: ["@slack/bolt"], +}); diff --git a/extensions/slack/src/group-policy.contract.test.ts b/extensions/slack/src/group-policy.contract.test.ts new file mode 100644 index 00000000000..d0ed8ead6ce --- /dev/null +++ b/extensions/slack/src/group-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installSlackGroupPolicyContractSuite } from "../../../test/helpers/channels/group-policy-contract.js"; + +describe("slack group policy contract", () => { + installSlackGroupPolicyContractSuite(); +}); diff --git a/extensions/slack/src/inbound.contract.test.ts b/extensions/slack/src/inbound.contract.test.ts new file mode 100644 index 00000000000..8c886f64465 --- /dev/null +++ b/extensions/slack/src/inbound.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installSlackInboundContractSuite } from "../../../test/helpers/channels/inbound-contract.js"; + +describe("slack inbound contract", () => { + installSlackInboundContractSuite(); +}); diff --git a/extensions/slack/src/outbound-payload.contract.test.ts b/extensions/slack/src/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..c04a012c544 --- /dev/null +++ b/extensions/slack/src/outbound-payload.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installSlackOutboundPayloadContractSuite } from "../../../test/helpers/channels/outbound-payload-contract.js"; + +describe("slack outbound payload contract", () => { + installSlackOutboundPayloadContractSuite(); +}); diff --git a/extensions/slack/src/plugins-core.contract.test.ts b/extensions/slack/src/plugins-core.contract.test.ts new file mode 100644 index 00000000000..f342057799a --- /dev/null +++ b/extensions/slack/src/plugins-core.contract.test.ts @@ -0,0 +1,3 @@ +import { describeSlackPluginsCoreExtensionContract } from "../../../test/helpers/channels/plugins-core-extension-contract.js"; + +describeSlackPluginsCoreExtensionContract(); diff --git a/extensions/slack/src/registry-backed.contract.test.ts b/extensions/slack/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..a417ba78a5f --- /dev/null +++ b/extensions/slack/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("slack"); diff --git a/extensions/synology-chat/package-manifest.contract.test.ts b/extensions/synology-chat/package-manifest.contract.test.ts new file mode 100644 index 00000000000..bbfc5b8971d --- /dev/null +++ b/extensions/synology-chat/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "synology-chat", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/synology-chat/src/registry-backed.contract.test.ts b/extensions/synology-chat/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..fc1b3c360f3 --- /dev/null +++ b/extensions/synology-chat/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("synology-chat"); diff --git a/extensions/synthetic/provider.contract.test.ts b/extensions/synthetic/provider.contract.test.ts new file mode 100644 index 00000000000..27f1688cc89 --- /dev/null +++ b/extensions/synthetic/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("synthetic"); diff --git a/extensions/tavily/bundled-web-search.contract.test.ts b/extensions/tavily/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..2fdcf4f44f6 --- /dev/null +++ b/extensions/tavily/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("tavily"); diff --git a/extensions/tavily/plugin-registration.contract.test.ts b/extensions/tavily/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..0f7ba946a0d --- /dev/null +++ b/extensions/tavily/plugin-registration.contract.test.ts @@ -0,0 +1,7 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "tavily", + webSearchProviderIds: ["tavily"], + toolNames: ["tavily_search", "tavily_extract"], +}); diff --git a/extensions/tavily/web-search-provider.contract.test.ts b/extensions/tavily/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..5b69dabaca9 --- /dev/null +++ b/extensions/tavily/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("tavily"); diff --git a/extensions/telegram/package-manifest.contract.test.ts b/extensions/telegram/package-manifest.contract.test.ts new file mode 100644 index 00000000000..5dbb3892a34 --- /dev/null +++ b/extensions/telegram/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "telegram", + runtimeDeps: ["grammy"], +}); diff --git a/extensions/telegram/src/group-policy.contract.test.ts b/extensions/telegram/src/group-policy.contract.test.ts new file mode 100644 index 00000000000..8d10e4a3759 --- /dev/null +++ b/extensions/telegram/src/group-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installTelegramGroupPolicyContractSuite } from "../../../test/helpers/channels/group-policy-contract.js"; + +describe("telegram group policy contract", () => { + installTelegramGroupPolicyContractSuite(); +}); diff --git a/extensions/telegram/src/inbound.contract.test.ts b/extensions/telegram/src/inbound.contract.test.ts new file mode 100644 index 00000000000..1dc5d7f7522 --- /dev/null +++ b/extensions/telegram/src/inbound.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installTelegramInboundContractSuite } from "../../../test/helpers/channels/inbound-contract.js"; + +describe("telegram inbound contract", () => { + installTelegramInboundContractSuite(); +}); diff --git a/extensions/telegram/src/plugins-core.contract.test.ts b/extensions/telegram/src/plugins-core.contract.test.ts new file mode 100644 index 00000000000..55fd90a379a --- /dev/null +++ b/extensions/telegram/src/plugins-core.contract.test.ts @@ -0,0 +1,3 @@ +import { describeTelegramPluginsCoreExtensionContract } from "../../../test/helpers/channels/plugins-core-extension-contract.js"; + +describeTelegramPluginsCoreExtensionContract(); diff --git a/extensions/telegram/src/registry-backed.contract.test.ts b/extensions/telegram/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..431d2f97210 --- /dev/null +++ b/extensions/telegram/src/registry-backed.contract.test.ts @@ -0,0 +1,7 @@ +import { + describeChannelRegistryBackedContracts, + describeSessionBindingRegistryBackedContract, +} from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("telegram"); +describeSessionBindingRegistryBackedContract("telegram"); diff --git a/extensions/tlon/package-manifest.contract.test.ts b/extensions/tlon/package-manifest.contract.test.ts new file mode 100644 index 00000000000..97ebf33f84c --- /dev/null +++ b/extensions/tlon/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "tlon", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/together/provider.contract.test.ts b/extensions/together/provider.contract.test.ts new file mode 100644 index 00000000000..0064291d7a8 --- /dev/null +++ b/extensions/together/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("together"); diff --git a/extensions/twitch/package-manifest.contract.test.ts b/extensions/twitch/package-manifest.contract.test.ts new file mode 100644 index 00000000000..b862514614c --- /dev/null +++ b/extensions/twitch/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "twitch", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/venice/provider-runtime.contract.test.ts b/extensions/venice/provider-runtime.contract.test.ts new file mode 100644 index 00000000000..54e30c2cf51 --- /dev/null +++ b/extensions/venice/provider-runtime.contract.test.ts @@ -0,0 +1,3 @@ +import { describeVeniceProviderRuntimeContract } from "../../test/helpers/extensions/provider-runtime-contract.js"; + +describeVeniceProviderRuntimeContract(); diff --git a/extensions/venice/provider.contract.test.ts b/extensions/venice/provider.contract.test.ts new file mode 100644 index 00000000000..8a6ec60fa52 --- /dev/null +++ b/extensions/venice/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("venice"); diff --git a/extensions/vercel-ai-gateway/provider.contract.test.ts b/extensions/vercel-ai-gateway/provider.contract.test.ts new file mode 100644 index 00000000000..7359a8960d2 --- /dev/null +++ b/extensions/vercel-ai-gateway/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("vercel-ai-gateway"); diff --git a/extensions/vllm/provider-discovery.contract.test.ts b/extensions/vllm/provider-discovery.contract.test.ts new file mode 100644 index 00000000000..a3cf06ca5ba --- /dev/null +++ b/extensions/vllm/provider-discovery.contract.test.ts @@ -0,0 +1,3 @@ +import { describeVllmProviderDiscoveryContract } from "../../test/helpers/extensions/provider-discovery-contract.js"; + +describeVllmProviderDiscoveryContract(); diff --git a/extensions/vllm/provider.contract.test.ts b/extensions/vllm/provider.contract.test.ts new file mode 100644 index 00000000000..980ae364f0b --- /dev/null +++ b/extensions/vllm/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("vllm"); diff --git a/extensions/voice-call/package-manifest.contract.test.ts b/extensions/voice-call/package-manifest.contract.test.ts new file mode 100644 index 00000000000..d932244267a --- /dev/null +++ b/extensions/voice-call/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "voice-call", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/volcengine/provider.contract.test.ts b/extensions/volcengine/provider.contract.test.ts new file mode 100644 index 00000000000..7923b1d1eeb --- /dev/null +++ b/extensions/volcengine/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("volcengine"); diff --git a/extensions/whatsapp/channel-catalog.contract.test.ts b/extensions/whatsapp/channel-catalog.contract.test.ts new file mode 100644 index 00000000000..491e96bea12 --- /dev/null +++ b/extensions/whatsapp/channel-catalog.contract.test.ts @@ -0,0 +1,31 @@ +import { + describeBundledMetadataOnlyChannelCatalogContract, + describeOfficialFallbackChannelCatalogContract, +} from "../../test/helpers/channels/channel-catalog-contract.js"; + +const whatsappMeta = { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp (QR link)", + detailLabel: "WhatsApp Web", + docsPath: "/channels/whatsapp", + blurb: "works with your own number; recommend a separate phone + eSIM.", +}; + +describeBundledMetadataOnlyChannelCatalogContract({ + pluginId: "whatsapp", + packageName: "@openclaw/whatsapp", + npmSpec: "@openclaw/whatsapp", + meta: whatsappMeta, + defaultChoice: "npm", +}); + +describeOfficialFallbackChannelCatalogContract({ + channelId: "whatsapp", + npmSpec: "@openclaw/whatsapp", + meta: whatsappMeta, + packageName: "@openclaw/whatsapp", + pluginId: "whatsapp", + externalNpmSpec: "@vendor/whatsapp-fork", + externalLabel: "WhatsApp Fork", +}); diff --git a/extensions/whatsapp/package-manifest.contract.test.ts b/extensions/whatsapp/package-manifest.contract.test.ts new file mode 100644 index 00000000000..77b44d61b19 --- /dev/null +++ b/extensions/whatsapp/package-manifest.contract.test.ts @@ -0,0 +1,7 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "whatsapp", + runtimeDeps: ["@whiskeysockets/baileys", "jimp"], + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/whatsapp/src/group-policy.contract.test.ts b/extensions/whatsapp/src/group-policy.contract.test.ts new file mode 100644 index 00000000000..b7fb1b37a17 --- /dev/null +++ b/extensions/whatsapp/src/group-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installWhatsAppGroupPolicyContractSuite } from "../../../test/helpers/channels/group-policy-contract.js"; + +describe("whatsapp group policy contract", () => { + installWhatsAppGroupPolicyContractSuite(); +}); diff --git a/extensions/whatsapp/src/inbound.contract.test.ts b/extensions/whatsapp/src/inbound.contract.test.ts new file mode 100644 index 00000000000..3bb13a55ef4 --- /dev/null +++ b/extensions/whatsapp/src/inbound.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installWhatsAppInboundContractSuite } from "../../../test/helpers/channels/inbound-contract.js"; + +describe("whatsapp inbound contract", () => { + installWhatsAppInboundContractSuite(); +}); diff --git a/extensions/whatsapp/src/outbound-payload.contract.test.ts b/extensions/whatsapp/src/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..9a48a59a436 --- /dev/null +++ b/extensions/whatsapp/src/outbound-payload.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installWhatsAppOutboundPayloadContractSuite } from "../../../test/helpers/channels/outbound-payload-contract.js"; + +describe("whatsapp outbound payload contract", () => { + installWhatsAppOutboundPayloadContractSuite(); +}); diff --git a/extensions/whatsapp/src/plugins-core.contract.test.ts b/extensions/whatsapp/src/plugins-core.contract.test.ts new file mode 100644 index 00000000000..91023faf523 --- /dev/null +++ b/extensions/whatsapp/src/plugins-core.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWhatsAppPluginsCoreExtensionContract } from "../../../test/helpers/channels/plugins-core-extension-contract.js"; + +describeWhatsAppPluginsCoreExtensionContract(); diff --git a/extensions/xai/bundled-web-search.contract.test.ts b/extensions/xai/bundled-web-search.contract.test.ts new file mode 100644 index 00000000000..87096e3fd6a --- /dev/null +++ b/extensions/xai/bundled-web-search.contract.test.ts @@ -0,0 +1,3 @@ +import { describeBundledWebSearchFastPathContract } from "../../test/helpers/extensions/bundled-web-search-fast-path-contract.js"; + +describeBundledWebSearchFastPathContract("xai"); diff --git a/extensions/xai/plugin-registration.contract.test.ts b/extensions/xai/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..eb17d5aeaf7 --- /dev/null +++ b/extensions/xai/plugin-registration.contract.test.ts @@ -0,0 +1,7 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "xai", + providerIds: ["xai"], + webSearchProviderIds: ["grok"], +}); diff --git a/extensions/xai/provider-runtime.contract.test.ts b/extensions/xai/provider-runtime.contract.test.ts new file mode 100644 index 00000000000..81df0b7a4fe --- /dev/null +++ b/extensions/xai/provider-runtime.contract.test.ts @@ -0,0 +1,3 @@ +import { describeXAIProviderRuntimeContract } from "../../test/helpers/extensions/provider-runtime-contract.js"; + +describeXAIProviderRuntimeContract(); diff --git a/extensions/xai/provider.contract.test.ts b/extensions/xai/provider.contract.test.ts new file mode 100644 index 00000000000..265876ce5e7 --- /dev/null +++ b/extensions/xai/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("xai"); diff --git a/extensions/xai/web-search-provider.contract.test.ts b/extensions/xai/web-search-provider.contract.test.ts new file mode 100644 index 00000000000..78ca60244fd --- /dev/null +++ b/extensions/xai/web-search-provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeWebSearchProviderContracts } from "../../test/helpers/extensions/web-search-provider-contract.js"; + +describeWebSearchProviderContracts("xai"); diff --git a/extensions/xiaomi/provider.contract.test.ts b/extensions/xiaomi/provider.contract.test.ts new file mode 100644 index 00000000000..f36675c83cc --- /dev/null +++ b/extensions/xiaomi/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("xiaomi"); diff --git a/extensions/zai/plugin-registration.contract.test.ts b/extensions/zai/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..8991f6efd6d --- /dev/null +++ b/extensions/zai/plugin-registration.contract.test.ts @@ -0,0 +1,7 @@ +import { describePluginRegistrationContract } from "../../test/helpers/extensions/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "zai", + mediaUnderstandingProviderIds: ["zai"], + requireDescribeImages: true, +}); diff --git a/extensions/zai/provider-runtime.contract.test.ts b/extensions/zai/provider-runtime.contract.test.ts new file mode 100644 index 00000000000..18f99d627e6 --- /dev/null +++ b/extensions/zai/provider-runtime.contract.test.ts @@ -0,0 +1,3 @@ +import { describeZAIProviderRuntimeContract } from "../../test/helpers/extensions/provider-runtime-contract.js"; + +describeZAIProviderRuntimeContract(); diff --git a/extensions/zai/provider.contract.test.ts b/extensions/zai/provider.contract.test.ts new file mode 100644 index 00000000000..e381dd6f4a1 --- /dev/null +++ b/extensions/zai/provider.contract.test.ts @@ -0,0 +1,3 @@ +import { describeProviderContracts } from "../../test/helpers/extensions/provider-contract.js"; + +describeProviderContracts("zai"); diff --git a/extensions/zalo/package-manifest.contract.test.ts b/extensions/zalo/package-manifest.contract.test.ts new file mode 100644 index 00000000000..39c69dbdc2d --- /dev/null +++ b/extensions/zalo/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "zalo", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/zalo/src/group-policy.contract.test.ts b/extensions/zalo/src/group-policy.contract.test.ts new file mode 100644 index 00000000000..f3a3c3320fa --- /dev/null +++ b/extensions/zalo/src/group-policy.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installZaloGroupPolicyContractSuite } from "../../../test/helpers/channels/group-policy-contract.js"; + +describe("zalo group policy contract", () => { + installZaloGroupPolicyContractSuite(); +}); diff --git a/extensions/zalo/src/outbound-payload.contract.test.ts b/extensions/zalo/src/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..d0cf41cb335 --- /dev/null +++ b/extensions/zalo/src/outbound-payload.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installZaloOutboundPayloadContractSuite } from "../../../test/helpers/channels/outbound-payload-contract.js"; + +describe("zalo outbound payload contract", () => { + installZaloOutboundPayloadContractSuite(); +}); diff --git a/extensions/zalo/src/registry-backed.contract.test.ts b/extensions/zalo/src/registry-backed.contract.test.ts new file mode 100644 index 00000000000..ca30b561b8a --- /dev/null +++ b/extensions/zalo/src/registry-backed.contract.test.ts @@ -0,0 +1,3 @@ +import { describeChannelRegistryBackedContracts } from "../../../test/helpers/channels/registry-backed-contract.js"; + +describeChannelRegistryBackedContracts("zalo"); diff --git a/extensions/zalouser/package-manifest.contract.test.ts b/extensions/zalouser/package-manifest.contract.test.ts new file mode 100644 index 00000000000..867700f142c --- /dev/null +++ b/extensions/zalouser/package-manifest.contract.test.ts @@ -0,0 +1,6 @@ +import { describePackageManifestContract } from "../../test/helpers/extensions/package-manifest-contract.js"; + +describePackageManifestContract({ + pluginId: "zalouser", + minHostVersionBaseline: "2026.3.22", +}); diff --git a/extensions/zalouser/src/outbound-payload.contract.test.ts b/extensions/zalouser/src/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..cdc7b00f244 --- /dev/null +++ b/extensions/zalouser/src/outbound-payload.contract.test.ts @@ -0,0 +1,6 @@ +import { describe } from "vitest"; +import { installZalouserOutboundPayloadContractSuite } from "../../../test/helpers/channels/outbound-payload-contract.js"; + +describe("zalouser outbound payload contract", () => { + installZalouserOutboundPayloadContractSuite(); +}); diff --git a/src/agents/models-config.providers.moonshot.test.ts b/src/agents/models-config.providers.moonshot.test.ts index 588666208c0..193218b1696 100644 --- a/src/agents/models-config.providers.moonshot.test.ts +++ b/src/agents/models-config.providers.moonshot.test.ts @@ -2,15 +2,14 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { - MOONSHOT_BASE_URL as MOONSHOT_AI_BASE_URL, - MOONSHOT_CN_BASE_URL, -} from "../../extensions/moonshot/api.js"; import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; import { applyNativeStreamingUsageCompat } from "./models-config.providers.js"; import { buildMoonshotProvider } from "./models-config.providers.static.js"; +const MOONSHOT_AI_BASE_URL = "https://api.moonshot.ai/v1"; +const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1"; + describe("moonshot implicit provider (#33637)", () => { it("uses explicit CN baseUrl when provided", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 15834b2782c..f4a4b31536e 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { CUSTOM_PROXY_MODELS_CONFIG, @@ -12,6 +13,63 @@ import { } from "./models-config.e2e-harness.js"; import type { ProviderConfig as ModelsProviderConfig } from "./models-config.providers.js"; +function createModel(id: string): ModelDefinitionConfig { + return { + id, + name: id, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128_000, + maxTokens: 8_192, + }; +} + +function buildDeepSeekProvider(): ModelsProviderConfig { + return { + baseUrl: "https://api.deepseek.com/v1", + api: "openai-completions", + models: [createModel("deepseek-chat")], + }; +} + +function buildMinimaxProvider(): ModelsProviderConfig { + return { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + models: [createModel("MiniMax-M2.7")], + }; +} + +function buildMistralProvider(): ModelsProviderConfig { + return { + baseUrl: "https://api.mistral.ai/v1", + api: "openai-completions", + models: [createModel("mistral-medium-latest")], + }; +} + +function buildSyntheticProvider(): ModelsProviderConfig { + return { + baseUrl: "https://api.synthetic.new/anthropic", + api: "anthropic-messages", + models: [createModel("hf:MiniMaxAI/MiniMax-M2.5")], + }; +} + +function buildXaiProvider(): ModelsProviderConfig { + return { + baseUrl: "https://api.x.ai/v1", + api: "openai-completions", + models: [createModel("grok-4-fast")], + }; +} + vi.mock("./auth-profiles/external-cli-sync.js", () => ({ syncExternalCliCredentials: () => false, })); @@ -20,19 +78,6 @@ vi.mock("./models-config.providers.js", async () => { const actual = await vi.importActual( "./models-config.providers.js", ); - const [ - { buildDeepSeekProvider }, - { buildMinimaxProvider }, - { buildMistralProvider }, - { buildSyntheticProvider }, - { buildXaiProvider }, - ] = await Promise.all([ - import("../../extensions/deepseek/provider-catalog.js"), - import("../../extensions/minimax/provider-catalog.js"), - import("../../extensions/mistral/provider-catalog.js"), - import("../../extensions/synthetic/provider-catalog.js"), - import("../../extensions/xai/provider-catalog.js"), - ]); return { ...actual, resolveImplicitProviders: async ({ env }: { env?: NodeJS.ProcessEnv }) => { diff --git a/src/agents/skills/source.ts b/src/agents/skills/source.ts index 5db39e3d440..33e8535f03d 100644 --- a/src/agents/skills/source.ts +++ b/src/agents/skills/source.ts @@ -1,5 +1,5 @@ import type { Skill } from "@mariozechner/pi-coding-agent"; export function resolveSkillSource(skill: Skill): string { - return skill.sourceInfo.source; + return (skill as Skill & { sourceInfo?: { source?: string } }).sourceInfo?.source ?? "unknown"; } diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index f1a082b297c..9f5ff25ead9 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -15,7 +15,7 @@ import { import * as hookRunnerGlobal from "../plugins/hook-runner-global.js"; import type { HookRunner } from "../plugins/hooks.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import * as piEmbedded from "./pi-embedded.js"; import * as agentStep from "./tools/agent-step.js"; @@ -190,7 +190,6 @@ vi.mock("./subagent-registry-runtime.js", () => subagentRegistryMock); describe("subagent announce formatting", () => { let previousFastTestEnv: string | undefined; let runSubagentAnnounceFlow: (typeof import("./subagent-announce.js"))["runSubagentAnnounceFlow"]; - let matrixPlugin: (typeof import("../../extensions/matrix/index.js"))["matrixPlugin"]; beforeAll(async () => { // Set FAST_TEST_MODE before importing the module to ensure the module-level @@ -199,7 +198,6 @@ describe("subagent announce formatting", () => { // See: https://github.com/openclaw/openclaw/issues/31298 previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; process.env.OPENCLAW_TEST_FAST = "1"; - ({ matrixPlugin } = await import("../../extensions/matrix/index.js")); ({ runSubagentAnnounceFlow } = await import("./subagent-announce.js")); }); @@ -316,7 +314,13 @@ describe("subagent announce formatting", () => { sessionStore = {}; sessionBindingServiceTesting.resetSessionBindingAdaptersForTests(); setActivePluginRegistry( - createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]), + createTestRegistry([ + { + pluginId: "matrix", + plugin: createChannelTestPluginBase({ id: "matrix", label: "Matrix" }), + source: "test", + }, + ]), ); setConfigOverride({ session: { diff --git a/src/channels/plugins/contracts/dm-policy.contract.test.ts b/src/channels/plugins/contracts/dm-policy.contract.test.ts deleted file mode 100644 index 58ee4ca4241..00000000000 --- a/src/channels/plugins/contracts/dm-policy.contract.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isAllowedBlueBubblesSender } from "../../../../extensions/bluebubbles/api.js"; -import { isMattermostSenderAllowed } from "../../../../extensions/mattermost/api.js"; -import { isSignalSenderAllowed, type SignalSender } from "../../../../extensions/signal/api.js"; -import { - DM_GROUP_ACCESS_REASON, - resolveDmGroupAccessWithLists, -} from "../../../security/dm-policy-shared.js"; - -type ChannelSmokeCase = { - name: string; - storeAllowFrom: string[]; - isSenderAllowed: (allowFrom: string[]) => boolean; -}; - -const signalSender: SignalSender = { - kind: "phone", - raw: "+15550001111", - e164: "+15550001111", -}; - -const cases: ChannelSmokeCase[] = [ - { - name: "bluebubbles", - storeAllowFrom: ["attacker-user"], - isSenderAllowed: (allowFrom) => - isAllowedBlueBubblesSender({ - allowFrom, - sender: "attacker-user", - chatId: 101, - }), - }, - { - name: "signal", - storeAllowFrom: [signalSender.e164], - isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom), - }, - { - name: "mattermost", - storeAllowFrom: ["user:attacker-user"], - isSenderAllowed: (allowFrom) => - isMattermostSenderAllowed({ - senderId: "attacker-user", - senderName: "Attacker", - allowFrom, - }), - }, -]; - -describe("security/dm-policy-shared channel smoke", () => { - for (const testCase of cases) { - for (const ingress of ["message", "reaction"] as const) { - it(`[${testCase.name}] blocks group ${ingress} when sender is only in pairing store`, () => { - const access = resolveDmGroupAccessWithLists({ - isGroup: true, - dmPolicy: "pairing", - groupPolicy: "allowlist", - allowFrom: ["owner-user"], - groupAllowFrom: ["group-owner"], - storeAllowFrom: testCase.storeAllowFrom, - isSenderAllowed: testCase.isSenderAllowed, - }); - expect(access.decision).toBe("block"); - expect(access.reasonCode).toBe(DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED); - expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); - }); - } - } -}); diff --git a/src/channels/plugins/contracts/group-policy.contract.test.ts b/src/channels/plugins/contracts/group-policy.contract.test.ts deleted file mode 100644 index af3e6daa95e..00000000000 --- a/src/channels/plugins/contracts/group-policy.contract.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { __testing as discordMonitorTesting } from "../../../../extensions/discord/src/monitor/provider.js"; -import { __testing as imessageMonitorTesting } from "../../../../extensions/imessage/src/monitor/monitor-provider.js"; -import { __testing as slackMonitorTesting } from "../../../../extensions/slack/src/monitor/provider.js"; -import { resolveTelegramRuntimeGroupPolicy } from "../../../../extensions/telegram/runtime-api.js"; -import { whatsappAccessControlTesting } from "../../../../extensions/whatsapp/api.js"; -import { - evaluateZaloGroupAccess, - resolveZaloRuntimeGroupPolicy, -} from "../../../../extensions/zalo/api.js"; -import { installChannelRuntimeGroupPolicyFallbackSuite } from "./suites.js"; - -describe("channel runtime group policy contract", () => { - describe("slack", () => { - installChannelRuntimeGroupPolicyFallbackSuite({ - resolve: slackMonitorTesting.resolveSlackRuntimeGroupPolicy, - configuredLabel: "keeps open default when channels.slack is configured", - defaultGroupPolicyUnderTest: "open", - missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); - }); - - describe("telegram", () => { - installChannelRuntimeGroupPolicyFallbackSuite({ - resolve: resolveTelegramRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.telegram is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set", - missingDefaultLabel: "ignores explicit defaults when provider config is missing", - }); - }); - - describe("whatsapp", () => { - installChannelRuntimeGroupPolicyFallbackSuite({ - resolve: whatsappAccessControlTesting.resolveWhatsAppRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.whatsapp is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); - }); - - describe("imessage", () => { - installChannelRuntimeGroupPolicyFallbackSuite({ - resolve: imessageMonitorTesting.resolveIMessageRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.imessage is configured", - defaultGroupPolicyUnderTest: "disabled", - missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); - }); - - describe("discord", () => { - installChannelRuntimeGroupPolicyFallbackSuite({ - resolve: discordMonitorTesting.resolveDiscordRuntimeGroupPolicy, - configuredLabel: "keeps open default when channels.discord is configured", - defaultGroupPolicyUnderTest: "open", - missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); - - it("respects explicit provider policy", () => { - const resolved = discordMonitorTesting.resolveDiscordRuntimeGroupPolicy({ - providerConfigPresent: false, - groupPolicy: "disabled", - }); - expect(resolved.groupPolicy).toBe("disabled"); - expect(resolved.providerMissingFallbackApplied).toBe(false); - }); - }); - - describe("zalo", () => { - installChannelRuntimeGroupPolicyFallbackSuite({ - resolve: resolveZaloRuntimeGroupPolicy, - configuredLabel: "keeps open fallback when channels.zalo is configured", - defaultGroupPolicyUnderTest: "open", - missingConfigLabel: "fails closed when channels.zalo is missing and no defaults are set", - missingDefaultLabel: "ignores explicit global defaults when provider config is missing", - }); - - it("keeps provider-owned group access evaluation", () => { - const decision = evaluateZaloGroupAccess({ - providerConfigPresent: true, - configuredGroupPolicy: "allowlist", - defaultGroupPolicy: "open", - groupAllowFrom: ["zl:12345"], - senderId: "12345", - }); - expect(decision).toMatchObject({ - allowed: true, - groupPolicy: "allowlist", - reason: "allowed", - }); - }); - }); -}); diff --git a/src/channels/plugins/contracts/plugins-core.contract.test.ts b/src/channels/plugins/contracts/plugins-core.contract.test.ts index 4eaa55798c7..01b0a51b0c3 100644 --- a/src/channels/plugins/contracts/plugins-core.contract.test.ts +++ b/src/channels/plugins/contracts/plugins-core.contract.test.ts @@ -1,44 +1,17 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; -import { - listDiscordDirectoryGroupsFromConfig, - listDiscordDirectoryPeersFromConfig, - type DiscordProbe, - type DiscordTokenResolution, -} from "../../../../extensions/discord/api.js"; -import type { IMessageProbe } from "../../../../extensions/imessage/api.js"; -import type { SignalProbe } from "../../../../extensions/signal/api.js"; -import { - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, - type SlackProbe, -} from "../../../../extensions/slack/api.js"; -import { - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, - type TelegramProbe, - type TelegramTokenResolution, -} from "../../../../extensions/telegram/api.js"; -import { - listWhatsAppDirectoryGroupsFromConfig, - listWhatsAppDirectoryPeersFromConfig, -} from "../../../../extensions/whatsapp/api.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import type { LineProbeResult } from "../../../plugin-sdk/line.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearPluginDiscoveryCache } from "../../../plugins/discovery.js"; import { clearPluginManifestRegistryCache } from "../../../plugins/manifest-registry.js"; import { setActivePluginRegistry } from "../../../plugins/runtime.js"; import { createChannelTestPluginBase, - createMSTeamsTestPluginBase, createOutboundTestPlugin, createTestRegistry, } from "../../../test-utils/channel-plugins.js"; -import { withEnvAsync } from "../../../test-utils/env.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js"; -import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "../catalog.js"; +import { listChannelPluginCatalogEntries } from "../catalog.js"; import { authorizeConfigWrite, canBypassConfigWritePolicy, @@ -50,13 +23,12 @@ import { import { listChannelPlugins } from "../index.js"; import { loadChannelPlugin } from "../load.js"; import { loadChannelOutboundAdapter } from "../outbound/load.js"; -import type { ChannelDirectoryEntry, ChannelOutboundAdapter, ChannelPlugin } from "../types.js"; -import type { BaseProbeResult, BaseTokenResolution } from "../types.js"; +import type { ChannelOutboundAdapter, ChannelPlugin } from "../types.js"; describe("channel plugin registry", () => { const emptyRegistry = createTestRegistry([]); - const createPlugin = (id: string): ChannelPlugin => ({ + const createPlugin = (id: string, order?: number): ChannelPlugin => ({ id, meta: { id, @@ -64,6 +36,7 @@ describe("channel plugin registry", () => { selectionLabel: id, docsPath: `/channels/${id}`, blurb: "test", + ...(order === undefined ? {} : { order }), }, capabilities: { chatTypes: ["direct"] }, config: { @@ -83,54 +56,48 @@ describe("channel plugin registry", () => { }); it("sorts channel plugins by configured order", () => { + const orderedPlugins: Array<[string, number]> = [ + ["demo-middle", 20], + ["demo-first", 10], + ["demo-last", 30], + ]; const registry = createTestRegistry( - ["slack", "telegram", "signal"].map((id) => ({ + orderedPlugins.map(([id, order]) => ({ pluginId: id, - plugin: createPlugin(id), + plugin: createPlugin(id, order), source: "test", })), ); setActivePluginRegistry(registry); const pluginIds = listChannelPlugins().map((plugin) => plugin.id); - expect(pluginIds).toEqual(["telegram", "slack", "signal"]); + expect(pluginIds).toEqual(["demo-first", "demo-middle", "demo-last"]); }); it("refreshes cached channel lookups when the same registry instance is re-activated", () => { const registry = createTestRegistry([ { - pluginId: "slack", - plugin: createPlugin("slack"), + pluginId: "demo-alpha", + plugin: createPlugin("demo-alpha"), source: "test", }, ]); setActivePluginRegistry(registry, "registry-test"); - expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["slack"]); + expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["demo-alpha"]); registry.channels = [ { - pluginId: "telegram", - plugin: createPlugin("telegram"), + pluginId: "demo-beta", + plugin: createPlugin("demo-beta"), source: "test", }, ] as typeof registry.channels; setActivePluginRegistry(registry, "registry-test"); - expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["telegram"]); + expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["demo-beta"]); }); }); describe("channel plugin catalog", () => { - it("includes Microsoft Teams", () => { - const entry = getChannelPluginCatalogEntry("msteams"); - expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); - expect(entry?.meta.aliases).toContain("teams"); - }); - - it("lists plugin catalog entries", () => { - const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); - expect(ids).toContain("msteams"); - }); - it("includes external catalog entries", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); const catalogPath = path.join(dir, "catalog.json"); @@ -283,180 +250,6 @@ describe("channel plugin catalog", () => { expect(ids).toContain("default-env-demo"); }); - it("includes bundled metadata-only channel entries even when the runtime entrypoint is omitted", () => { - const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-")); - const bundledDir = path.join(packageRoot, "dist", "extensions", "whatsapp"); - fs.mkdirSync(bundledDir, { recursive: true }); - fs.writeFileSync( - path.join(packageRoot, "package.json"), - JSON.stringify({ name: "openclaw" }), - "utf8", - ); - fs.writeFileSync( - path.join(bundledDir, "package.json"), - JSON.stringify({ - name: "@openclaw/whatsapp", - openclaw: { - extensions: ["./index.js"], - channel: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp (QR link)", - detailLabel: "WhatsApp Web", - docsPath: "/channels/whatsapp", - blurb: "works with your own number; recommend a separate phone + eSIM.", - }, - install: { - npmSpec: "@openclaw/whatsapp", - defaultChoice: "npm", - }, - }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(bundledDir, "openclaw.plugin.json"), - JSON.stringify({ id: "whatsapp", channels: ["whatsapp"], configSchema: {} }), - "utf8", - ); - - const entry = listChannelPluginCatalogEntries({ - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"), - }, - }).find((item) => item.id === "whatsapp"); - - expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp"); - expect(entry?.pluginId).toBe("whatsapp"); - }); - - it("includes shipped official channel catalog entries when bundled metadata is omitted", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-official-catalog-")); - const catalogPath = path.join(dir, "channel-catalog.json"); - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [ - { - name: "@openclaw/whatsapp", - openclaw: { - channel: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp (QR link)", - detailLabel: "WhatsApp Web", - docsPath: "/channels/whatsapp", - blurb: "works with your own number; recommend a separate phone + eSIM.", - }, - install: { - npmSpec: "@openclaw/whatsapp", - defaultChoice: "npm", - }, - }, - }, - ], - }), - ); - - const entry = listChannelPluginCatalogEntries({ - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - officialCatalogPaths: [catalogPath], - }).find((item) => item.id === "whatsapp"); - - expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp"); - expect(entry?.pluginId).toBeUndefined(); - }); - - it("lets external catalogs override shipped fallback channel metadata", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-fallback-catalog-")); - const bundledDir = path.join(dir, "dist", "extensions", "whatsapp"); - const officialCatalogPath = path.join(dir, "channel-catalog.json"); - const externalCatalogPath = path.join(dir, "catalog.json"); - fs.mkdirSync(bundledDir, { recursive: true }); - fs.writeFileSync( - path.join(bundledDir, "package.json"), - JSON.stringify({ - name: "@openclaw/whatsapp", - openclaw: { - channel: { - id: "whatsapp", - label: "WhatsApp Bundled", - selectionLabel: "WhatsApp Bundled", - docsPath: "/channels/whatsapp", - blurb: "bundled fallback", - }, - install: { - npmSpec: "@openclaw/whatsapp", - }, - }, - }), - "utf8", - ); - fs.writeFileSync( - officialCatalogPath, - JSON.stringify({ - entries: [ - { - name: "@openclaw/whatsapp", - openclaw: { - channel: { - id: "whatsapp", - label: "WhatsApp Official", - selectionLabel: "WhatsApp Official", - docsPath: "/channels/whatsapp", - blurb: "official fallback", - }, - install: { - npmSpec: "@openclaw/whatsapp", - }, - }, - }, - ], - }), - "utf8", - ); - fs.writeFileSync( - externalCatalogPath, - JSON.stringify({ - entries: [ - { - name: "@vendor/whatsapp-fork", - openclaw: { - channel: { - id: "whatsapp", - label: "WhatsApp Fork", - selectionLabel: "WhatsApp Fork", - docsPath: "/channels/whatsapp", - blurb: "external override", - }, - install: { - npmSpec: "@vendor/whatsapp-fork", - }, - }, - }, - ], - }), - "utf8", - ); - - const entry = listChannelPluginCatalogEntries({ - catalogPaths: [externalCatalogPath], - officialCatalogPaths: [officialCatalogPath], - env: { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(dir, "dist", "extensions"), - }, - }).find((item) => item.id === "whatsapp"); - - expect(entry?.install.npmSpec).toBe("@vendor/whatsapp-fork"); - expect(entry?.meta.label).toBe("WhatsApp Fork"); - expect(entry?.pluginId).toBeUndefined(); - }); - it("keeps discovered plugins ahead of external catalog overrides", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-state-")); const pluginDir = path.join(stateDir, "extensions", "demo-channel-plugin"); @@ -533,50 +326,63 @@ describe("channel plugin catalog", () => { const emptyRegistry = createTestRegistry([]); -const msteamsOutbound: ChannelOutboundAdapter = { +const demoOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", - sendText: async () => ({ channel: "msteams", messageId: "m1" }), - sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), + sendText: async () => ({ channel: "demo-loader", messageId: "m1" }), + sendMedia: async () => ({ channel: "demo-loader", messageId: "m2" }), }; -const msteamsPlugin: ChannelPlugin = { - ...createMSTeamsTestPluginBase(), - outbound: msteamsOutbound, +const demoLoaderPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "demo-loader", + label: "Demo Loader", + config: { listAccountIds: () => [], resolveAccount: () => ({}) }, + }), + outbound: demoOutbound, }; -const registryWithMSTeams = createTestRegistry([ - { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, +const registryWithDemoLoader = createTestRegistry([ + { pluginId: "demo-loader", plugin: demoLoaderPlugin, source: "test" }, ]); -const msteamsOutboundV2: ChannelOutboundAdapter = { +const demoOutboundV2: ChannelOutboundAdapter = { deliveryMode: "direct", - sendText: async () => ({ channel: "msteams", messageId: "m3" }), - sendMedia: async () => ({ channel: "msteams", messageId: "m4" }), + sendText: async () => ({ channel: "demo-loader", messageId: "m3" }), + sendMedia: async () => ({ channel: "demo-loader", messageId: "m4" }), }; -const msteamsPluginV2 = createOutboundTestPlugin({ - id: "msteams", - label: "Microsoft Teams", - outbound: msteamsOutboundV2, +const demoLoaderPluginV2 = createOutboundTestPlugin({ + id: "demo-loader", + label: "Demo Loader", + outbound: demoOutboundV2, }); -const registryWithMSTeamsV2 = createTestRegistry([ - { pluginId: "msteams", plugin: msteamsPluginV2, source: "test-v2" }, +const registryWithDemoLoaderV2 = createTestRegistry([ + { pluginId: "demo-loader", plugin: demoLoaderPluginV2, source: "test-v2" }, ]); -const mstNoOutboundPlugin = createChannelTestPluginBase({ - id: "msteams", - label: "Microsoft Teams", +const demoNoOutboundPlugin = createChannelTestPluginBase({ + id: "demo-loader", + label: "Demo Loader", }); -const registryWithMSTeamsNoOutbound = createTestRegistry([ - { pluginId: "msteams", plugin: mstNoOutboundPlugin, source: "test-no-outbound" }, +const registryWithDemoLoaderNoOutbound = createTestRegistry([ + { pluginId: "demo-loader", plugin: demoNoOutboundPlugin, source: "test-no-outbound" }, ]); -function makeSlackConfigWritesCfg(accountIdKey: string) { +const demoOriginChannelId = "demo-origin"; +const demoTargetChannelId = "demo-target"; + +function makeDemoConfigWritesCfg(accountIdKey: string) { return { channels: { - slack: { + [demoOriginChannelId]: { + configWrites: true, + accounts: { + [accountIdKey]: { configWrites: false }, + }, + }, + [demoTargetChannelId]: { configWrites: true, accounts: { [accountIdKey]: { configWrites: false }, @@ -586,33 +392,6 @@ function makeSlackConfigWritesCfg(accountIdKey: string) { }; } -type DirectoryListFn = (params: { - cfg: OpenClawConfig; - accountId?: string | null; - query?: string | null; - limit?: number | null; -}) => Promise; - -async function listDirectoryEntriesWithDefaults(listFn: DirectoryListFn, cfg: OpenClawConfig) { - return await listFn({ - cfg, - accountId: "default", - query: null, - limit: null, - }); -} - -async function expectDirectoryIds( - listFn: DirectoryListFn, - cfg: OpenClawConfig, - expected: string[], - options?: { sorted?: boolean }, -) { - const entries = await listDirectoryEntriesWithDefaults(listFn, cfg); - const ids = entries.map((entry) => entry.id); - expect(options?.sorted ? ids.toSorted() : ids).toEqual(expected); -} - describe("channel plugin loader", () => { beforeEach(() => { setActivePluginRegistry(emptyRegistry); @@ -625,89 +404,60 @@ describe("channel plugin loader", () => { }); it("loads channel plugins from the active registry", async () => { - setActivePluginRegistry(registryWithMSTeams); - const plugin = await loadChannelPlugin("msteams"); - expect(plugin).toBe(msteamsPlugin); + setActivePluginRegistry(registryWithDemoLoader); + const plugin = await loadChannelPlugin("demo-loader"); + expect(plugin).toBe(demoLoaderPlugin); }); it("loads outbound adapters from registered plugins", async () => { - setActivePluginRegistry(registryWithMSTeams); - const outbound = await loadChannelOutboundAdapter("msteams"); - expect(outbound).toBe(msteamsOutbound); + setActivePluginRegistry(registryWithDemoLoader); + const outbound = await loadChannelOutboundAdapter("demo-loader"); + expect(outbound).toBe(demoOutbound); }); it("refreshes cached plugin values when registry changes", async () => { - setActivePluginRegistry(registryWithMSTeams); - expect(await loadChannelPlugin("msteams")).toBe(msteamsPlugin); - setActivePluginRegistry(registryWithMSTeamsV2); - expect(await loadChannelPlugin("msteams")).toBe(msteamsPluginV2); + setActivePluginRegistry(registryWithDemoLoader); + expect(await loadChannelPlugin("demo-loader")).toBe(demoLoaderPlugin); + setActivePluginRegistry(registryWithDemoLoaderV2); + expect(await loadChannelPlugin("demo-loader")).toBe(demoLoaderPluginV2); }); it("refreshes cached outbound values when registry changes", async () => { - setActivePluginRegistry(registryWithMSTeams); - expect(await loadChannelOutboundAdapter("msteams")).toBe(msteamsOutbound); - setActivePluginRegistry(registryWithMSTeamsV2); - expect(await loadChannelOutboundAdapter("msteams")).toBe(msteamsOutboundV2); + setActivePluginRegistry(registryWithDemoLoader); + expect(await loadChannelOutboundAdapter("demo-loader")).toBe(demoOutbound); + setActivePluginRegistry(registryWithDemoLoaderV2); + expect(await loadChannelOutboundAdapter("demo-loader")).toBe(demoOutboundV2); }); it("returns undefined when plugin has no outbound adapter", async () => { - setActivePluginRegistry(registryWithMSTeamsNoOutbound); - expect(await loadChannelOutboundAdapter("msteams")).toBeUndefined(); - }); -}); - -describe("BaseProbeResult assignability", () => { - it("TelegramProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("DiscordProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("SlackProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("SignalProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("IMessageProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("LineProbeResult satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); -}); - -describe("BaseTokenResolution assignability", () => { - it("Telegram and Discord token resolutions satisfy BaseTokenResolution", () => { - expectTypeOf().toMatchTypeOf(); - expectTypeOf().toMatchTypeOf(); + setActivePluginRegistry(registryWithDemoLoaderNoOutbound); + expect(await loadChannelOutboundAdapter("demo-loader")).toBeUndefined(); }); }); describe("resolveChannelConfigWrites", () => { it("defaults to allow when unset", () => { const cfg = {}; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); + expect(resolveChannelConfigWrites({ cfg, channelId: demoOriginChannelId })).toBe(true); }); it("blocks when channel config disables writes", () => { - const cfg = { channels: { slack: { configWrites: false } } }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); + const cfg = { channels: { [demoOriginChannelId]: { configWrites: false } } }; + expect(resolveChannelConfigWrites({ cfg, channelId: demoOriginChannelId })).toBe(false); }); it("account override wins over channel default", () => { - const cfg = makeSlackConfigWritesCfg("work"); - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + const cfg = makeDemoConfigWritesCfg("work"); + expect( + resolveChannelConfigWrites({ cfg, channelId: demoOriginChannelId, accountId: "work" }), + ).toBe(false); }); it("matches account ids case-insensitively", () => { - const cfg = makeSlackConfigWritesCfg("Work"); - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + const cfg = makeDemoConfigWritesCfg("Work"); + expect( + resolveChannelConfigWrites({ cfg, channelId: demoOriginChannelId, accountId: "work" }), + ).toBe(false); }); }); @@ -719,9 +469,12 @@ describe("authorizeConfigWrite", () => { }) { expect( authorizeConfigWrite({ - cfg: makeSlackConfigWritesCfg(params.disabledAccountId), - origin: { channelId: "slack", accountId: "default" }, - target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }), + cfg: makeDemoConfigWritesCfg(params.disabledAccountId), + origin: { channelId: demoOriginChannelId, accountId: "default" }, + target: resolveExplicitConfigWriteTarget({ + channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId, + accountId: "work", + }), }), ).toEqual({ allowed: false, @@ -729,7 +482,7 @@ describe("authorizeConfigWrite", () => { blockedScope: { kind: params.blockedScope, scope: { - channelId: "slack", + channelId: params.blockedScope === "target" ? demoTargetChannelId : demoOriginChannelId, accountId: params.blockedScope === "target" ? "work" : "default", }, }, @@ -753,12 +506,15 @@ describe("authorizeConfigWrite", () => { }); it("allows bypass for internal operator.admin writes", () => { - const cfg = makeSlackConfigWritesCfg("work"); + const cfg = makeDemoConfigWritesCfg("work"); expect( authorizeConfigWrite({ cfg, - origin: { channelId: "slack", accountId: "default" }, - target: resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" }), + origin: { channelId: demoOriginChannelId, accountId: "default" }, + target: resolveExplicitConfigWriteTarget({ + channelId: demoTargetChannelId, + accountId: "work", + }), allowBypass: canBypassConfigWritePolicy({ channel: INTERNAL_MESSAGE_CHANNEL, gatewayClientScopes: ["operator.admin"], @@ -768,35 +524,37 @@ describe("authorizeConfigWrite", () => { }); it("treats non-channel config paths as global writes", () => { - const cfg = makeSlackConfigWritesCfg("work"); + const cfg = makeDemoConfigWritesCfg("work"); expect( authorizeConfigWrite({ cfg, - origin: { channelId: "slack", accountId: "default" }, + origin: { channelId: demoOriginChannelId, accountId: "default" }, target: resolveConfigWriteTargetFromPath(["messages", "ackReaction"]), }), ).toEqual({ allowed: true }); }); it("rejects ambiguous channel collection writes", () => { - expect(resolveConfigWriteTargetFromPath(["channels", "telegram"])).toEqual({ + expect(resolveConfigWriteTargetFromPath(["channels", "demo-channel"])).toEqual({ kind: "ambiguous", - scopes: [{ channelId: "telegram" }], + scopes: [{ channelId: "demo-channel" }], }); - expect(resolveConfigWriteTargetFromPath(["channels", "telegram", "accounts"])).toEqual({ + expect(resolveConfigWriteTargetFromPath(["channels", "demo-channel", "accounts"])).toEqual({ kind: "ambiguous", - scopes: [{ channelId: "telegram" }], + scopes: [{ channelId: "demo-channel" }], }); }); it("resolves explicit channel and account targets", () => { - expect(resolveExplicitConfigWriteTarget({ channelId: "slack" })).toEqual({ + expect(resolveExplicitConfigWriteTarget({ channelId: demoOriginChannelId })).toEqual({ kind: "channel", - scope: { channelId: "slack" }, + scope: { channelId: demoOriginChannelId }, }); - expect(resolveExplicitConfigWriteTarget({ channelId: "slack", accountId: "work" })).toEqual({ + expect( + resolveExplicitConfigWriteTarget({ channelId: demoTargetChannelId, accountId: "work" }), + ).toEqual({ kind: "account", - scope: { channelId: "slack", accountId: "work" }, + scope: { channelId: demoTargetChannelId, accountId: "work" }, }); }); @@ -806,243 +564,12 @@ describe("authorizeConfigWrite", () => { result: { allowed: false, reason: "target-disabled", - blockedScope: { kind: "target", scope: { channelId: "slack", accountId: "work" } }, + blockedScope: { + kind: "target", + scope: { channelId: demoTargetChannelId, accountId: "work" }, + }, }, }), - ).toContain("channels.slack.accounts.work.configWrites=true"); - }); -}); - -describe("directory (config-backed)", () => { - it("lists Slack peers/groups from config", async () => { - const cfg = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - dm: { allowFrom: ["U123", "user:U999"] }, - dms: { U234: {} }, - channels: { C111: { users: ["U777"] } }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - await expectDirectoryIds( - listSlackDirectoryPeersFromConfig, - cfg, - ["user:u123", "user:u234", "user:u777", "user:u999"], - { sorted: true }, - ); - await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]); - }); - - it("lists Discord peers/groups from config (numeric ids only)", async () => { - const cfg = { - channels: { - discord: { - token: "discord-test", - dm: { allowFrom: ["<@111>", "<@!333>", "nope"] }, - dms: { "222": {} }, - guilds: { - "123": { - users: ["<@12345>", " discord:444 ", "not-an-id"], - channels: { - "555": {}, - "<#777>": {}, - "channel:666": {}, - general: {}, - }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - await expectDirectoryIds( - listDiscordDirectoryPeersFromConfig, - cfg, - ["user:111", "user:12345", "user:222", "user:333", "user:444"], - { sorted: true }, - ); - await expectDirectoryIds( - listDiscordDirectoryGroupsFromConfig, - cfg, - ["channel:555", "channel:666", "channel:777"], - { sorted: true }, - ); - }); - - it("lists Telegram peers/groups from config", async () => { - const cfg = { - channels: { - telegram: { - botToken: "telegram-test", - allowFrom: ["123", "alice", "tg:@bob"], - dms: { "456": {} }, - groups: { "-1001": {}, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - await expectDirectoryIds( - listTelegramDirectoryPeersFromConfig, - cfg, - ["123", "456", "@alice", "@bob"], - { - sorted: true, - }, - ); - await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); - }); - - it("keeps Telegram config-backed directory fallback semantics when accountId is omitted", async () => { - await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => { - const cfg = { - channels: { - telegram: { - allowFrom: ["alice"], - groups: { "-1001": {} }, - accounts: { - work: { - botToken: "tok-work", - allowFrom: ["bob"], - groups: { "-2002": {} }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]); - await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); - }); - }); - - it("keeps config-backed directories readable when channel tokens are unresolved SecretRefs", async () => { - const envSecret = { - source: "env", - provider: "default", - id: "MISSING_TEST_SECRET", - } as const; - const cfg = { - channels: { - slack: { - botToken: envSecret, - appToken: envSecret, - dm: { allowFrom: ["U123"] }, - channels: { C111: {} }, - }, - discord: { - token: envSecret, - dm: { allowFrom: ["<@111>"] }, - guilds: { - "123": { - channels: { - "555": {}, - }, - }, - }, - }, - telegram: { - botToken: envSecret, - allowFrom: ["alice"], - groups: { "-1001": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]); - await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]); - await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]); - await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]); - await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]); - await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); - }); - - it("lists WhatsApp peers/groups from config", async () => { - const cfg = { - channels: { - whatsapp: { - allowFrom: ["+15550000000", "*", "123@g.us"], - groups: { "999@g.us": { requireMention: true }, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - await expectDirectoryIds(listWhatsAppDirectoryPeersFromConfig, cfg, ["+15550000000"]); - await expectDirectoryIds(listWhatsAppDirectoryGroupsFromConfig, cfg, ["999@g.us"]); - }); - - it("applies query and limit filtering for config-backed directories", async () => { - const cfg = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - dm: { allowFrom: ["U100", "U200"] }, - dms: { U300: {} }, - channels: { C111: {}, C222: {}, C333: {} }, - }, - discord: { - token: "discord-test", - guilds: { - "123": { - channels: { - "555": {}, - "666": {}, - "777": {}, - }, - }, - }, - }, - telegram: { - botToken: "telegram-test", - groups: { "-1001": {}, "-1002": {}, "-2001": {} }, - }, - whatsapp: { - groups: { "111@g.us": {}, "222@g.us": {}, "333@s.whatsapp.net": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const slackPeers = await listSlackDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: "user:u", - limit: 2, - }); - expect(slackPeers).toHaveLength(2); - expect(slackPeers.every((entry) => entry.id.startsWith("user:u"))).toBe(true); - - const discordGroups = await listDiscordDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: "666", - limit: 5, - }); - expect(discordGroups.map((entry) => entry.id)).toEqual(["channel:666"]); - - const telegramGroups = await listTelegramDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: "-100", - limit: 1, - }); - expect(telegramGroups.map((entry) => entry.id)).toEqual(["-1001"]); - - const whatsAppGroups = await listWhatsAppDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: "@g.us", - limit: 1, - }); - expect(whatsAppGroups.map((entry) => entry.id)).toEqual(["111@g.us"]); + ).toContain(`channels.${demoTargetChannelId}.accounts.work.configWrites=true`); }); }); diff --git a/src/channels/plugins/contracts/registry-backed.contract.test.ts b/src/channels/plugins/contracts/registry-backed.contract.test.ts deleted file mode 100644 index 9f441748b90..00000000000 --- a/src/channels/plugins/contracts/registry-backed.contract.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe } from "vitest"; -import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/runtime-api.js"; -import { feishuThreadBindingTesting } from "../../../../extensions/feishu/api.js"; -import { resetMatrixThreadBindingsForTests } from "../../../../extensions/matrix/api.js"; -import { __testing as telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js"; -import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js"; -import { - actionContractRegistry, - directoryContractRegistry, - pluginContractRegistry, - sessionBindingContractRegistry, - setupContractRegistry, - statusContractRegistry, - surfaceContractRegistry, - threadingContractRegistry, -} from "./registry.js"; -import { - installChannelActionsContractSuite, - installChannelDirectoryContractSuite, - installChannelPluginContractSuite, - installChannelSetupContractSuite, - installChannelStatusContractSuite, - installChannelSurfaceContractSuite, - installChannelThreadingContractSuite, - installSessionBindingContractSuite, -} from "./suites.js"; - -for (const entry of pluginContractRegistry) { - describe(`${entry.id} plugin contract`, () => { - installChannelPluginContractSuite({ - plugin: entry.plugin, - }); - }); -} - -for (const entry of actionContractRegistry) { - describe(`${entry.id} actions contract`, () => { - installChannelActionsContractSuite({ - plugin: entry.plugin, - cases: entry.cases as never, - unsupportedAction: entry.unsupportedAction as never, - }); - }); -} - -for (const entry of setupContractRegistry) { - describe(`${entry.id} setup contract`, () => { - installChannelSetupContractSuite({ - plugin: entry.plugin, - cases: entry.cases as never, - }); - }); -} - -for (const entry of statusContractRegistry) { - describe(`${entry.id} status contract`, () => { - installChannelStatusContractSuite({ - plugin: entry.plugin, - cases: entry.cases as never, - }); - }); -} - -for (const entry of surfaceContractRegistry) { - for (const surface of entry.surfaces) { - describe(`${entry.id} ${surface} surface contract`, () => { - installChannelSurfaceContractSuite({ - plugin: entry.plugin, - surface, - }); - }); - } -} - -for (const entry of threadingContractRegistry) { - describe(`${entry.id} threading contract`, () => { - installChannelThreadingContractSuite({ - plugin: entry.plugin, - }); - }); -} - -for (const entry of directoryContractRegistry) { - describe(`${entry.id} directory contract`, () => { - installChannelDirectoryContractSuite({ - plugin: entry.plugin, - coverage: entry.coverage, - cfg: entry.cfg, - accountId: entry.accountId, - }); - }); -} - -describe("session binding contract registry", () => { - beforeEach(async () => { - sessionBindingTesting.resetSessionBindingAdaptersForTests(); - discordThreadBindingTesting.resetThreadBindingsForTests(); - feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); - resetMatrixThreadBindingsForTests(); - await telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); - }); - - for (const entry of sessionBindingContractRegistry) { - describe(`${entry.id} session binding contract`, () => { - installSessionBindingContractSuite({ - expectedCapabilities: entry.expectedCapabilities, - getCapabilities: entry.getCapabilities, - bindAndResolve: entry.bindAndResolve, - unbindAndVerify: entry.unbindAndVerify, - cleanup: entry.cleanup, - }); - }); - } -}); diff --git a/src/channels/plugins/contracts/registry.contract.test.ts b/src/channels/plugins/contracts/registry.contract.test.ts index dfe26afc154..eda365c7b1d 100644 --- a/src/channels/plugins/contracts/registry.contract.test.ts +++ b/src/channels/plugins/contracts/registry.contract.test.ts @@ -1,23 +1,12 @@ import { describe, expect, it } from "vitest"; -import { feishuSessionBindingAdapterChannels } from "../../../../extensions/feishu/api.js"; -import { matrixSessionBindingAdapterChannels } from "../../../../extensions/matrix/api.js"; import { sessionBindingContractChannelIds } from "./manifest.js"; -function discoverSessionBindingChannels() { - return [ - ...new Set([ - ...discordSessionBindingAdapterChannels, - ...feishuSessionBindingAdapterChannels, - ...matrixSessionBindingAdapterChannels, - "telegram", - ]), - ].toSorted(); -} - const discordSessionBindingAdapterChannels = ["discord"] as const; describe("channel contract registry", () => { - it("keeps session binding coverage aligned with registered session binding adapters", () => { - expect([...sessionBindingContractChannelIds]).toEqual(discoverSessionBindingChannels()); + it("keeps core session binding coverage aligned with built-in adapters", () => { + expect([...sessionBindingContractChannelIds]).toEqual( + expect.arrayContaining([...discordSessionBindingAdapterChannels, "telegram"]), + ); }); }); diff --git a/src/cli/directory-cli.test.ts b/src/cli/directory-cli.test.ts index ac742476d8d..5d455d7173e 100644 --- a/src/cli/directory-cli.test.ts +++ b/src/cli/directory-cli.test.ts @@ -56,8 +56,8 @@ describe("registerDirectoryCli", () => { mocks.writeConfigFile.mockResolvedValue(undefined); mocks.resolveChannelDefaultAccountId.mockReturnValue("default"); mocks.resolveMessageChannelSelection.mockResolvedValue({ - channel: "slack", - configured: ["slack"], + channel: "demo-channel", + configured: ["demo-channel"], source: "explicit", }); getRuntimeCapture().defaultRuntime.exit.mockImplementation((code: number) => { @@ -70,11 +70,11 @@ describe("registerDirectoryCli", () => { mocks.resolveInstallableChannelPlugin.mockResolvedValue({ cfg: { channels: {}, - plugins: { entries: { whatsapp: { enabled: true } } }, + plugins: { entries: { "demo-directory": { enabled: true } } }, }, - channelId: "whatsapp", + channelId: "demo-directory", plugin: { - id: "whatsapp", + id: "demo-directory", directory: { self }, }, configChanged: true, @@ -83,19 +83,19 @@ describe("registerDirectoryCli", () => { const program = new Command().name("openclaw"); registerDirectoryCli(program); - await program.parseAsync(["directory", "self", "--channel", "whatsapp", "--json"], { + await program.parseAsync(["directory", "self", "--channel", "demo-directory", "--json"], { from: "user", }); expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith( expect.objectContaining({ - rawChannel: "whatsapp", + rawChannel: "demo-directory", allowInstall: true, }), ); expect(mocks.writeConfigFile).toHaveBeenCalledWith( expect.objectContaining({ - plugins: { entries: { whatsapp: { enabled: true } } }, + plugins: { entries: { "demo-directory": { enabled: true } } }, }), ); expect(self).toHaveBeenCalledWith( diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 4927eb12537..1039a627c9c 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1,12 +1,11 @@ import fs from "node:fs/promises"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { MINIMAX_CN_API_BASE_URL } from "../../extensions/minimax/api.js"; -import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL } from "../../extensions/zai/api.js"; import { resolveAgentDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { ModelProviderConfig } from "../config/types.models.js"; +import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL } from "../plugin-sdk/zai.js"; import { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; import { providerApiKeyAuthRuntime } from "../plugins/provider-api-key-auth.runtime.js"; import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; @@ -26,6 +25,8 @@ import { type DetectZaiEndpoint = typeof import("./zai-endpoint-detect.js").detectZaiEndpoint; +const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; + const loginOpenAICodexOAuth = vi.hoisted(() => vi.fn<() => Promise>(async () => null), ); diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 73f9d68dc7e..ff6c90b29eb 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -2,12 +2,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL } from "../../extensions/minimax/api.js"; import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, ZAI_GLOBAL_BASE_URL, -} from "../../extensions/zai/api.js"; +} from "../plugin-sdk/zai.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { withEnvAsync } from "../test-utils/env.js"; import { @@ -23,6 +22,9 @@ type OnboardEnv = { }; type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; +const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; +const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; + const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})); vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async () => { @@ -30,156 +32,582 @@ vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async { resolveDefaultAgentId, resolveAgentDir, resolveAgentWorkspaceDir }, { resolveDefaultAgentWorkspaceDir }, { enablePluginInConfig }, - { default: minimaxPlugin }, - { default: zaiPlugin }, - { default: xaiPlugin }, - { default: volcenginePlugin }, - { default: byteplusPlugin }, - { default: anthropicPlugin }, - { default: litellmPlugin }, - { default: mistralPlugin }, - { default: togetherPlugin }, - { default: qianfanPlugin }, - { default: modelstudioPlugin }, - { default: openaiPlugin }, - { default: openrouterPlugin }, - { default: opencodePlugin }, - { default: sglangPlugin }, - { default: cloudflareAiGatewayPlugin }, - { default: vercelAiGatewayPlugin }, - { default: vllmPlugin }, + { upsertAuthProfile }, + { createProviderApiKeyAuthMethod }, + { providerApiKeyAuthRuntime }, + { configureOpenAICompatibleSelfHostedProviderNonInteractive }, + { detectZaiEndpoint }, + { OPENAI_DEFAULT_MODEL }, ] = await Promise.all([ import("../agents/agent-scope.js"), import("../agents/workspace.js"), import("../plugins/enable.js"), - import("../../extensions/minimax/index.ts"), - import("../../extensions/zai/index.ts"), - import("../../extensions/xai/index.ts"), - import("../../extensions/volcengine/index.ts"), - import("../../extensions/byteplus/index.ts"), - import("../../extensions/anthropic/index.ts"), - import("../../extensions/litellm/index.ts"), - import("../../extensions/mistral/index.ts"), - import("../../extensions/together/index.ts"), - import("../../extensions/qianfan/index.ts"), - import("../../extensions/modelstudio/index.ts"), - import("../../extensions/openai/index.ts"), - import("../../extensions/openrouter/index.ts"), - import("../../extensions/opencode/index.ts"), - import("../../extensions/sglang/index.ts"), - import("../../extensions/cloudflare-ai-gateway/index.ts"), - import("../../extensions/vercel-ai-gateway/index.ts"), - import("../../extensions/vllm/index.ts"), + import("../agents/auth-profiles/profiles.js"), + import("../plugins/provider-api-key-auth.js"), + import("../plugins/provider-api-key-auth.runtime.js"), + import("../plugins/provider-self-hosted-setup.js"), + import("./zai-endpoint-detect.js"), + import("./openai-model-default.js"), ]); - type MockProvider = { - id: string; + const ZAI_FALLBACKS = { + "zai-api-key": { + baseUrl: ZAI_GLOBAL_BASE_URL, + modelId: "glm-5", + }, + "zai-coding-cn": { + baseUrl: ZAI_CODING_CN_BASE_URL, + modelId: "glm-4.7", + }, + "zai-coding-global": { + baseUrl: ZAI_CODING_GLOBAL_BASE_URL, + modelId: "glm-5", + }, + } as const; + + type HandlerContext = { + authChoice: string; + config: Record; + baseConfig: Record; + opts: Record; + runtime: { + error: (message: string) => void; + exit: (code: number) => void; + log: (s: string) => void; + }; + agentDir?: string; + workspaceDir?: string; + resolveApiKey: (input: { + provider: string; + flagValue?: string; + flagName: `--${string}`; + envVar: string; + envVarName?: string; + allowProfile?: boolean; + required?: boolean; + }) => Promise<{ + key: string; + source: "profile" | "env" | "flag"; + envVarName?: string; + } | null>; + toApiKeyCredential: (input: { + provider: string; + resolved: { + key: string; + source: "profile" | "env" | "flag"; + envVarName?: string; + }; + email?: string; + metadata?: Record; + }) => Record | null; + }; + + type ChoiceHandler = { + providerId: string; label: string; pluginId?: string; - auth?: Array<{ - id: string; - wizard?: { choiceId?: string }; - runNonInteractive?: (ctx: Record) => Promise; - }>; - wizard?: { setup?: { choiceId?: string; methodId?: string } }; + runNonInteractive: (ctx: HandlerContext) => Promise; }; - const providers: MockProvider[] = []; - const api = { - registerProvider(provider: MockProvider) { - providers.push(provider); + function normalizeText(value: unknown): string { + return typeof value === "string" ? value.replaceAll("\r", "").replaceAll("\n", "").trim() : ""; + } + + function withProviderConfig( + cfg: Record, + providerId: string, + patch: Record, + ): Record { + const models = + cfg.models && typeof cfg.models === "object" ? (cfg.models as Record) : {}; + const providers = + models.providers && typeof models.providers === "object" + ? (models.providers as Record) + : {}; + const existing = + providers[providerId] && typeof providers[providerId] === "object" + ? (providers[providerId] as Record) + : {}; + return { + ...cfg, + models: { + ...models, + providers: { + ...providers, + [providerId]: { + ...existing, + ...patch, + }, + }, + }, + }; + } + + function buildTestProviderModel( + id: string, + params?: { + reasoning?: boolean; + input?: Array<"text" | "image">; + contextWindow?: number; + maxTokens?: number; + }, + ): Record { + return { + id, + name: id, + reasoning: params?.reasoning ?? false, + input: params?.input ?? ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: params?.contextWindow ?? 131072, + maxTokens: params?.maxTokens ?? 16384, + }; + } + + function createApiKeyChoice(params: { + providerId: string; + label: string; + optionKey: string; + flagName: `--${string}`; + envVar: string; + choiceId: string; + pluginId?: string; + defaultModel?: string; + profileId?: string; + profileIds?: string[]; + applyConfig?: (cfg: Record) => Record; + }): ChoiceHandler { + const method = createProviderApiKeyAuthMethod({ + providerId: params.providerId, + methodId: "api-key", + label: params.label, + optionKey: params.optionKey, + flagName: params.flagName, + envVar: params.envVar, + promptMessage: `Enter ${params.label} API key`, + ...(params.profileId ? { profileId: params.profileId } : {}), + ...(params.profileIds ? { profileIds: params.profileIds } : {}), + ...(params.defaultModel ? { defaultModel: params.defaultModel } : {}), + ...(params.applyConfig + ? { applyConfig: (cfg) => params.applyConfig!(cfg as Record) as never } + : {}), + wizard: { + choiceId: params.choiceId, + choiceLabel: params.label, + groupId: params.providerId, + groupLabel: params.label, + }, + }); + return { + providerId: params.providerId, + label: params.label, + ...(params.pluginId ? { pluginId: params.pluginId } : {}), + runNonInteractive: async (ctx) => await method.runNonInteractive!(ctx as never), + }; + } + + function createSelfHostedChoice(params: { + providerId: string; + label: string; + defaultBaseUrl: string; + defaultApiKeyEnvVar: string; + modelPlaceholder: string; + }): ChoiceHandler { + return { + providerId: params.providerId, + label: params.label, + runNonInteractive: async (ctx) => + await configureOpenAICompatibleSelfHostedProviderNonInteractive({ + ctx: ctx as never, + providerId: params.providerId, + providerLabel: params.label, + defaultBaseUrl: params.defaultBaseUrl, + defaultApiKeyEnvVar: params.defaultApiKeyEnvVar, + modelPlaceholder: params.modelPlaceholder, + }), + }; + } + + function createZaiChoice( + choiceId: "zai-api-key" | "zai-coding-cn" | "zai-coding-global", + ): ChoiceHandler { + return { + providerId: "zai", + label: "Z.AI", + runNonInteractive: async (ctx) => { + const resolved = await ctx.resolveApiKey({ + provider: "zai", + flagValue: normalizeText(ctx.opts.zaiApiKey), + flagName: "--zai-api-key", + envVar: "ZAI_API_KEY", + }); + if (!resolved) { + return null; + } + if (resolved.source !== "profile") { + const credential = ctx.toApiKeyCredential({ provider: "zai", resolved }); + if (!credential) { + return null; + } + upsertAuthProfile({ + profileId: "zai:default", + credential: credential as never, + agentDir: ctx.agentDir, + }); + } + const detected = await detectZaiEndpoint({ + apiKey: resolved.key, + ...(choiceId === "zai-coding-global" + ? { endpoint: "coding-global" as const } + : choiceId === "zai-coding-cn" + ? { endpoint: "coding-cn" as const } + : {}), + }); + const fallback = ZAI_FALLBACKS[choiceId]; + let next = providerApiKeyAuthRuntime.applyAuthProfileConfig(ctx.config as never, { + profileId: "zai:default", + provider: "zai", + mode: "api_key", + }) as Record; + next = withProviderConfig(next, "zai", { + baseUrl: detected?.baseUrl ?? fallback.baseUrl, + api: "openai-completions", + models: [ + buildTestProviderModel(detected?.modelId ?? fallback.modelId, { + input: ["text"], + }), + ], + }); + return providerApiKeyAuthRuntime.applyPrimaryModel( + next as never, + `zai/${detected?.modelId ?? fallback.modelId}`, + ); + }, + }; + } + + const cloudflareAiGatewayChoice: ChoiceHandler = { + providerId: "cloudflare-ai-gateway", + label: "Cloudflare AI Gateway", + runNonInteractive: async (ctx) => { + const accountId = normalizeText(ctx.opts.cloudflareAiGatewayAccountId); + const gatewayId = normalizeText(ctx.opts.cloudflareAiGatewayGatewayId); + const resolved = await ctx.resolveApiKey({ + provider: "cloudflare-ai-gateway", + flagValue: normalizeText(ctx.opts.cloudflareAiGatewayApiKey), + flagName: "--cloudflare-ai-gateway-api-key", + envVar: "CLOUDFLARE_AI_GATEWAY_API_KEY", + }); + if (!resolved) { + return null; + } + if (resolved.source !== "profile") { + const credential = ctx.toApiKeyCredential({ + provider: "cloudflare-ai-gateway", + resolved, + metadata: { accountId, gatewayId }, + }); + if (!credential) { + return null; + } + upsertAuthProfile({ + profileId: "cloudflare-ai-gateway:default", + credential: credential as never, + agentDir: ctx.agentDir, + }); + } + const withProfile = providerApiKeyAuthRuntime.applyAuthProfileConfig(ctx.config as never, { + profileId: "cloudflare-ai-gateway:default", + provider: "cloudflare-ai-gateway", + mode: "api_key", + }); + return providerApiKeyAuthRuntime.applyPrimaryModel( + withProfile, + "cloudflare-ai-gateway/claude-sonnet-4-5", + ); }, - registerCliBackend() {}, - registerWebSearchProvider() {}, - registerMediaUnderstandingProvider() {}, - registerSpeechProvider() {}, - registerImageGenerationProvider() {}, }; - for (const plugin of [ - minimaxPlugin, - zaiPlugin, - xaiPlugin, - volcenginePlugin, - byteplusPlugin, - anthropicPlugin, - litellmPlugin, - mistralPlugin, - togetherPlugin, - qianfanPlugin, - modelstudioPlugin, - openaiPlugin, - openrouterPlugin, - opencodePlugin, - sglangPlugin, - cloudflareAiGatewayPlugin, - vercelAiGatewayPlugin, - vllmPlugin, - ]) { - void plugin.register(api as never); - } - - const choiceMap = new Map< - string, - { - provider: MockProvider; - method: NonNullable[number]; - } - >(); - - for (const provider of providers) { - for (const method of provider.auth ?? []) { - const choiceId = method.wizard?.choiceId?.trim(); - if (choiceId) { - choiceMap.set(choiceId, { provider, method }); + const anthropicTokenChoice: ChoiceHandler = { + providerId: "anthropic", + label: "Anthropic", + runNonInteractive: async (ctx) => { + const token = normalizeText(ctx.opts.token); + if (!token) { + ctx.runtime.error("Missing --token for --auth-choice token."); + ctx.runtime.exit(1); + return null; } - } + upsertAuthProfile({ + profileId: "anthropic:default", + credential: { + type: "token", + provider: "anthropic", + token, + }, + agentDir: ctx.agentDir, + }); + return providerApiKeyAuthRuntime.applyAuthProfileConfig(ctx.config as never, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "token", + }); + }, + }; - const setupChoiceId = provider.wizard?.setup?.choiceId?.trim(); - if (!setupChoiceId) { - continue; - } - const setupMethodId = provider.wizard?.setup?.methodId?.trim(); - const setupMethod = - (provider.auth ?? []).find((method) => method.id === setupMethodId) ?? provider.auth?.[0]; - if (setupMethod) { - choiceMap.set(setupChoiceId, { provider, method: setupMethod }); - } - } + const choiceMap = new Map([ + [ + "apiKey", + createApiKeyChoice({ + providerId: "anthropic", + label: "Anthropic", + choiceId: "apiKey", + optionKey: "anthropicApiKey", + flagName: "--anthropic-api-key", + envVar: "ANTHROPIC_API_KEY", + }), + ], + [ + "minimax-global-api", + createApiKeyChoice({ + providerId: "minimax", + label: "MiniMax", + choiceId: "minimax-global-api", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + profileId: "minimax:global", + defaultModel: "minimax/MiniMax-M2.7", + applyConfig: (cfg) => + withProviderConfig(cfg, "minimax", { + baseUrl: MINIMAX_API_BASE_URL, + api: "anthropic-messages", + models: [buildTestProviderModel("MiniMax-M2.7")], + }), + }), + ], + [ + "minimax-cn-api", + createApiKeyChoice({ + providerId: "minimax", + label: "MiniMax", + choiceId: "minimax-cn-api", + optionKey: "minimaxApiKey", + flagName: "--minimax-api-key", + envVar: "MINIMAX_API_KEY", + profileId: "minimax:cn", + defaultModel: "minimax/MiniMax-M2.7", + applyConfig: (cfg) => + withProviderConfig(cfg, "minimax", { + baseUrl: MINIMAX_CN_API_BASE_URL, + api: "anthropic-messages", + models: [buildTestProviderModel("MiniMax-M2.7")], + }), + }), + ], + ["zai-api-key", createZaiChoice("zai-api-key")], + ["zai-coding-cn", createZaiChoice("zai-coding-cn")], + ["zai-coding-global", createZaiChoice("zai-coding-global")], + [ + "xai-api-key", + createApiKeyChoice({ + providerId: "xai", + label: "xAI", + choiceId: "xai-api-key", + optionKey: "xaiApiKey", + flagName: "--xai-api-key", + envVar: "XAI_API_KEY", + defaultModel: "xai/grok-4", + }), + ], + [ + "mistral-api-key", + createApiKeyChoice({ + providerId: "mistral", + label: "Mistral", + choiceId: "mistral-api-key", + optionKey: "mistralApiKey", + flagName: "--mistral-api-key", + envVar: "MISTRAL_API_KEY", + defaultModel: "mistral/mistral-large-latest", + }), + ], + [ + "volcengine-api-key", + createApiKeyChoice({ + providerId: "volcengine", + label: "Volcano Engine", + choiceId: "volcengine-api-key", + optionKey: "volcengineApiKey", + flagName: "--volcengine-api-key", + envVar: "VOLCANO_ENGINE_API_KEY", + defaultModel: "volcengine-plan/ark-code-latest", + }), + ], + [ + "byteplus-api-key", + createApiKeyChoice({ + providerId: "byteplus", + label: "BytePlus", + choiceId: "byteplus-api-key", + optionKey: "byteplusApiKey", + flagName: "--byteplus-api-key", + envVar: "BYTEPLUS_API_KEY", + defaultModel: "byteplus-plan/ark-code-latest", + }), + ], + [ + "ai-gateway-api-key", + createApiKeyChoice({ + providerId: "vercel-ai-gateway", + label: "Vercel AI Gateway", + choiceId: "ai-gateway-api-key", + optionKey: "aiGatewayApiKey", + flagName: "--ai-gateway-api-key", + envVar: "AI_GATEWAY_API_KEY", + defaultModel: "vercel-ai-gateway/anthropic/claude-opus-4.6", + }), + ], + [ + "openai-api-key", + createApiKeyChoice({ + providerId: "openai", + label: "OpenAI", + choiceId: "openai-api-key", + optionKey: "openaiApiKey", + flagName: "--openai-api-key", + envVar: "OPENAI_API_KEY", + defaultModel: OPENAI_DEFAULT_MODEL, + }), + ], + [ + "openrouter-api-key", + createApiKeyChoice({ + providerId: "openrouter", + label: "OpenRouter", + choiceId: "openrouter-api-key", + optionKey: "openrouterApiKey", + flagName: "--openrouter-api-key", + envVar: "OPENROUTER_API_KEY", + }), + ], + [ + "opencode-zen", + createApiKeyChoice({ + providerId: "opencode", + label: "OpenCode", + choiceId: "opencode-zen", + optionKey: "opencodeApiKey", + flagName: "--opencode-api-key", + envVar: "OPENCODE_ZEN_API_KEY", + profileIds: ["opencode:default", "opencode-go:default"], + defaultModel: "opencode/claude-opus-4-6", + }), + ], + [ + "vllm", + createSelfHostedChoice({ + providerId: "vllm", + label: "vLLM", + defaultBaseUrl: "http://127.0.0.1:8000/v1", + defaultApiKeyEnvVar: "VLLM_API_KEY", + modelPlaceholder: "Qwen/Qwen3-32B", + }), + ], + [ + "sglang", + createSelfHostedChoice({ + providerId: "sglang", + label: "SGLang", + defaultBaseUrl: "http://127.0.0.1:30000/v1", + defaultApiKeyEnvVar: "SGLANG_API_KEY", + modelPlaceholder: "Qwen/Qwen3-32B", + }), + ], + [ + "litellm-api-key", + createApiKeyChoice({ + providerId: "litellm", + label: "LiteLLM", + choiceId: "litellm-api-key", + optionKey: "litellmApiKey", + flagName: "--litellm-api-key", + envVar: "LITELLM_API_KEY", + defaultModel: "litellm/claude-opus-4-6", + }), + ], + ["cloudflare-ai-gateway-api-key", cloudflareAiGatewayChoice], + [ + "together-api-key", + createApiKeyChoice({ + providerId: "together", + label: "Together", + choiceId: "together-api-key", + optionKey: "togetherApiKey", + flagName: "--together-api-key", + envVar: "TOGETHER_API_KEY", + defaultModel: "together/moonshotai/Kimi-K2.5", + }), + ], + [ + "qianfan-api-key", + createApiKeyChoice({ + providerId: "qianfan", + label: "Qianfan", + choiceId: "qianfan-api-key", + optionKey: "qianfanApiKey", + flagName: "--qianfan-api-key", + envVar: "QIANFAN_API_KEY", + defaultModel: "qianfan/deepseek-v3.2", + }), + ], + [ + "modelstudio-api-key", + createApiKeyChoice({ + providerId: "modelstudio", + label: "Model Studio", + choiceId: "modelstudio-api-key", + optionKey: "modelstudioApiKey", + flagName: "--modelstudio-api-key", + envVar: "MODELSTUDIO_API_KEY", + defaultModel: "modelstudio/qwen3.5-plus", + applyConfig: (cfg) => + withProviderConfig(cfg, "modelstudio", { + baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", + api: "openai-completions", + models: [buildTestProviderModel("qwen3.5-plus")], + }), + }), + ], + ["token", anthropicTokenChoice], + ]); return { applyNonInteractivePluginProviderChoice: async (params: { nextConfig: Record; authChoice: string; opts: Record; - runtime: { error: (message: string) => void; exit: (code: number) => void }; + runtime: HandlerContext["runtime"]; baseConfig: Record; - resolveApiKey: (input: Record) => Promise; - toApiKeyCredential: (input: Record) => Record | null; + resolveApiKey: HandlerContext["resolveApiKey"]; + toApiKeyCredential: HandlerContext["toApiKeyCredential"]; }) => { - const resolved = choiceMap.get(params.authChoice); - if (!resolved) { + const handler = choiceMap.get(params.authChoice); + if (!handler) { return undefined; } const enableResult = enablePluginInConfig( params.nextConfig as never, - resolved.provider.pluginId ?? resolved.provider.id, + handler.pluginId ?? handler.providerId, ); if (!enableResult.enabled) { params.runtime.error( - `${resolved.provider.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`, - ); - params.runtime.exit(1); - return null; - } - - if (!resolved.method.runNonInteractive) { - params.runtime.error( - [ - `Auth choice "${params.authChoice}" requires interactive mode.`, - `The ${resolved.provider.label} provider plugin does not implement non-interactive setup.`, - ].join("\n"), + `${handler.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`, ); params.runtime.exit(1); return null; @@ -190,7 +618,7 @@ vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async const workspaceDir = resolveAgentWorkspaceDir(enableResult.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); - return await resolved.method.runNonInteractive({ + return await handler.runNonInteractive({ authChoice: params.authChoice, config: enableResult.config, baseConfig: params.baseConfig, diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index bc2defccdb5..068ea69bc99 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -2,7 +2,7 @@ import type { IncomingMessage } from "node:http"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createMSTeamsTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../test-utils/imessage-test-plugin.js"; import { extractHookToken, @@ -15,6 +15,22 @@ import { resolveHooksConfig, } from "./hooks.js"; +const createDemoAliasPlugin = () => ({ + ...createChannelTestPluginBase({ + id: "demo-alias-channel", + label: "Demo Alias Channel", + docsPath: "/channels/demo-alias-channel", + }), + meta: { + ...createChannelTestPluginBase({ + id: "demo-alias-channel", + label: "Demo Alias Channel", + docsPath: "/channels/demo-alias-channel", + }).meta, + aliases: ["workspace-chat"], + }, +}); + describe("gateway hooks helpers", () => { const resolveHooksConfigOrThrow = (cfg: OpenClawConfig) => { const resolved = resolveHooksConfig(cfg); @@ -128,16 +144,16 @@ describe("gateway hooks helpers", () => { setActivePluginRegistry( createTestRegistry([ { - pluginId: "msteams", + pluginId: "demo-alias-channel", source: "test", - plugin: createMSTeamsTestPlugin({ aliases: ["teams"] }), + plugin: createDemoAliasPlugin(), }, ]), ); - const teams = normalizeAgentPayload({ message: "yo", channel: "teams" }); - expect(teams.ok).toBe(true); - if (teams.ok) { - expect(teams.value.channel).toBe("msteams"); + const aliasChannel = normalizeAgentPayload({ message: "yo", channel: "workspace-chat" }); + expect(aliasChannel.ok).toBe(true); + if (aliasChannel.ok) { + expect(aliasChannel.value.channel).toBe("demo-alias-channel"); } const bad = normalizeAgentPayload({ message: "yo", channel: "sms" }); diff --git a/src/gateway/server.send-telegram-target-writeback-scope.test.ts b/src/gateway/server.send-telegram-target-writeback-scope.test.ts index 01321263f5f..57ad908ecb1 100644 --- a/src/gateway/server.send-telegram-target-writeback-scope.test.ts +++ b/src/gateway/server.send-telegram-target-writeback-scope.test.ts @@ -2,11 +2,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { - sendMessageTelegram, - sendPollTelegram, - type TelegramApiOverride, -} from "../../extensions/telegram/test-api.js"; import { clearConfigCache, loadConfig, @@ -15,6 +10,11 @@ import { } from "../config/config.js"; import { loadCronStore, saveCronStore } from "../cron/store.js"; import type { CronStoreFile } from "../cron/types.js"; +import { + sendMessageTelegram, + sendPollTelegram, + type TelegramApiOverride, +} from "../plugin-sdk/telegram-runtime.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { getActivePluginRegistry, diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 6167c3c250c..6162a3e59cb 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createMSTeamsTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; @@ -103,31 +106,31 @@ describe("sendMessage channel normalization", () => { it.each([ { - name: "normalizes Teams aliases", + name: "normalizes plugin aliases", registry: createTestRegistry([ { - pluginId: "msteams", + pluginId: "demo-alias-channel", source: "test", - plugin: createMSTeamsTestPlugin({ - outbound: createMSTeamsOutbound(), - aliases: ["teams"], + plugin: createDemoAliasPlugin({ + outbound: createDemoAliasOutbound(), + aliases: ["workspace-chat"], }), }, ]), params: { - to: "conversation:19:abc@thread.tacv2", - channel: "teams", + to: "conversation:demo-target", + channel: "workspace-chat", deps: { - sendMSTeams: vi.fn(async () => ({ + sendDemoAliasChannel: vi.fn(async () => ({ messageId: "m1", conversationId: "c1", })), }, }, - assertDeps: (deps: { sendMSTeams?: ReturnType }) => { - expect(deps.sendMSTeams).toHaveBeenCalledWith("conversation:19:abc@thread.tacv2", "hi"); + assertDeps: (deps: { sendDemoAliasChannel?: ReturnType }) => { + expect(deps.sendDemoAliasChannel).toHaveBeenCalledWith("conversation:demo-target", "hi"); }, - expectedChannel: "msteams", + expectedChannel: "demo-alias-channel", }, { name: "normalizes iMessage aliases", @@ -209,16 +212,16 @@ describe("sendMessage replyToId threading", () => { }); describe("sendPoll channel normalization", () => { - it("normalizes Teams alias for polls", async () => { + it("normalizes plugin aliases for polls", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "p1" }); setRegistry( createTestRegistry([ { - pluginId: "msteams", + pluginId: "demo-alias-channel", source: "test", - plugin: createMSTeamsTestPlugin({ - aliases: ["teams"], - outbound: createMSTeamsOutbound({ includePoll: true }), + plugin: createDemoAliasPlugin({ + aliases: ["workspace-chat"], + outbound: createDemoAliasOutbound({ includePoll: true }), }), }, ]), @@ -226,17 +229,17 @@ describe("sendPoll channel normalization", () => { const result = await sendPoll({ cfg: {}, - to: "conversation:19:abc@thread.tacv2", + to: "conversation:demo-target", question: "Lunch?", options: ["Pizza", "Sushi"], - channel: "Teams", + channel: "Workspace-Chat", }); const call = callGatewayMock.mock.calls[0]?.[0] as { params?: Record; }; - expect(call?.params?.channel).toBe("msteams"); - expect(result.channel).toBe("msteams"); + expect(call?.params?.channel).toBe("demo-alias-channel"); + expect(result.channel).toBe("demo-alias-channel"); }); }); @@ -305,32 +308,54 @@ describe("gateway url override hardening", () => { const emptyRegistry = createTestRegistry([]); -const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({ +const createDemoAliasPlugin = (params?: { + aliases?: string[]; + outbound?: ChannelOutboundAdapter; +}): ChannelPlugin => ({ + ...createChannelTestPluginBase({ + id: "demo-alias-channel", + label: "Demo Alias Channel", + docsPath: "/channels/demo-alias-channel", + config: { listAccountIds: () => [], resolveAccount: () => ({}) }, + }), + meta: { + ...createChannelTestPluginBase({ + id: "demo-alias-channel", + label: "Demo Alias Channel", + docsPath: "/channels/demo-alias-channel", + config: { listAccountIds: () => [], resolveAccount: () => ({}) }, + }).meta, + ...(params?.aliases ? { aliases: params.aliases } : {}), + }, + ...(params?.outbound ? { outbound: params.outbound } : {}), +}); + +const createDemoAliasOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({ deliveryMode: "direct", sendText: async ({ deps, to, text }) => { - const send = deps?.sendMSTeams as + const send = deps?.sendDemoAliasChannel as | ((to: string, text: string, opts?: unknown) => Promise<{ messageId: string }>) | undefined; if (!send) { - throw new Error("sendMSTeams missing"); + throw new Error("sendDemoAliasChannel missing"); } const result = await send(to, text); - return { channel: "msteams", ...result }; + return { channel: "demo-alias-channel", ...result }; }, sendMedia: async ({ deps, to, text, mediaUrl }) => { - const send = deps?.sendMSTeams as + const send = deps?.sendDemoAliasChannel as | ((to: string, text: string, opts?: unknown) => Promise<{ messageId: string }>) | undefined; if (!send) { - throw new Error("sendMSTeams missing"); + throw new Error("sendDemoAliasChannel missing"); } const result = await send(to, text, { mediaUrl }); - return { channel: "msteams", ...result }; + return { channel: "demo-alias-channel", ...result }; }, ...(opts?.includePoll ? { pollMaxOptions: 12, - sendPoll: async () => ({ channel: "msteams", messageId: "p1" }), + sendPoll: async () => ({ channel: "demo-alias-channel", messageId: "p1" }), } : {}), }); diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index eb63e247645..f14c3b6bf1f 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -126,6 +126,18 @@ function createAudioConfigWithoutEchoFlag() { return { cfg, providers }; } +function createRegistryMediaProviders(): Record { + const createAudioProvider = (id: string): MediaUnderstandingProvider => ({ + id, + capabilities: ["audio"], + transcribeAudio: async () => ({ text: "transcribed text" }), + }); + return { + groq: createAudioProvider("groq"), + deepgram: createAudioProvider("deepgram"), + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -165,19 +177,15 @@ describe("applyMediaUnderstanding – echo transcript", () => { })); vi.doMock("./provider-registry.js", async (importOriginal) => { const actual = await importOriginal(); - const { deepgramMediaUnderstandingProvider } = - await import("../../extensions/deepgram/media-understanding-provider.js"); - const { groqMediaUnderstandingProvider } = - await import("../../extensions/groq/media-understanding-provider.js"); + const registryProviders = createRegistryMediaProviders(); return { ...actual, buildMediaUnderstandingRegistry: ( overrides?: Record, ) => { - const registry = new Map([ - ["groq", groqMediaUnderstandingProvider], - ["deepgram", deepgramMediaUnderstandingProvider], - ]); + const registry = new Map( + Object.entries(registryProviders), + ); for (const [key, provider] of Object.entries(overrides ?? {})) { const normalizedKey = actual.normalizeMediaProviderId(key); const existing = registry.get(normalizedKey); diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 122807fad3c..a6f206a3a7b 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -79,6 +79,18 @@ function createGroqProviders(transcribedText = "transcribed text") { }; } +function createRegistryMediaProviders(): Record { + const createAudioProvider = (id: string): MediaUnderstandingProvider => ({ + id, + capabilities: ["audio"], + transcribeAudio: async () => ({ text: "transcribed text" }), + }); + return { + groq: createAudioProvider("groq"), + deepgram: createAudioProvider("deepgram"), + }; +} + function expectTranscriptApplied(params: { ctx: MsgContext; transcript: string; @@ -248,19 +260,15 @@ describe("applyMediaUnderstanding", () => { })); vi.doMock("./provider-registry.js", async (importOriginal) => { const actual = await importOriginal(); - const { deepgramMediaUnderstandingProvider } = - await import("../../extensions/deepgram/media-understanding-provider.js"); - const { groqMediaUnderstandingProvider } = - await import("../../extensions/groq/media-understanding-provider.js"); + const registryProviders = createRegistryMediaProviders(); return { ...actual, buildMediaUnderstandingRegistry: ( overrides?: Record, ) => { - const registry = new Map([ - ["groq", groqMediaUnderstandingProvider], - ["deepgram", deepgramMediaUnderstandingProvider], - ]); + const registry = new Map( + Object.entries(registryProviders), + ); for (const [key, provider] of Object.entries(overrides ?? {})) { const normalizedKey = actual.normalizeMediaProviderId(key); const existing = registry.get(normalizedKey); diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts deleted file mode 100644 index 83ee008bc7c..00000000000 --- a/src/plugins/bundled-runtime-deps.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -type PackageManifest = { - dependencies?: Record; -}; - -function readJson(relativePath: string): T { - const absolutePath = path.resolve(process.cwd(), relativePath); - return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as T; -} - -describe("bundled plugin runtime dependencies", () => { - function expectPluginOwnsRuntimeDep(pluginPath: string, dependencyName: string) { - const rootManifest = readJson("package.json"); - const pluginManifest = readJson(pluginPath); - const pluginSpec = pluginManifest.dependencies?.[dependencyName]; - const rootSpec = rootManifest.dependencies?.[dependencyName]; - - expect(pluginSpec).toBeTruthy(); - expect(rootSpec).toBeUndefined(); - } - - it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => { - expectPluginOwnsRuntimeDep("extensions/feishu/package.json", "@larksuiteoapi/node-sdk"); - }); - - it("keeps memory-lancedb runtime deps plugin-local so packaged installs fetch them on demand", () => { - expectPluginOwnsRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb"); - }); - - it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => { - expectPluginOwnsRuntimeDep("extensions/discord/package.json", "@buape/carbon"); - }); - - it("keeps bundled Slack runtime deps plugin-local instead of mirroring them into the root package", () => { - expectPluginOwnsRuntimeDep("extensions/slack/package.json", "@slack/bolt"); - }); - - it("keeps bundled Telegram runtime deps plugin-local instead of mirroring them into the root package", () => { - expectPluginOwnsRuntimeDep("extensions/telegram/package.json", "grammy"); - }); - - it("keeps WhatsApp runtime deps plugin-local so packaged installs fetch them on demand", () => { - expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "@whiskeysockets/baileys"); - }); - - it("keeps WhatsApp image helper deps plugin-local so bundled builds resolve Baileys peers", () => { - expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "jimp"); - }); - - it("keeps bundled proxy-agent deps plugin-local instead of mirroring them into the root package", () => { - expectPluginOwnsRuntimeDep("extensions/discord/package.json", "https-proxy-agent"); - }); -}); diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts index 3d8096b95fa..2c1876ab9a3 100644 --- a/src/plugins/bundled-web-search.test.ts +++ b/src/plugins/bundled-web-search.test.ts @@ -1,85 +1,22 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js"; import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-web-search-ids.js"; -import { resolveBundledWebSearchPluginId } from "./bundled-web-search-provider-ids.js"; import { listBundledWebSearchProviders, resolveBundledWebSearchPluginIds, } from "./bundled-web-search.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; describe("bundled web search metadata", () => { - function toComparableEntry(params: { - pluginId: string; - provider: { - id: string; - label: string; - hint: string; - envVars: string[]; - placeholder: string; - signupUrl: string; - docsUrl?: string; - autoDetectOrder?: number; - requiresCredential?: boolean; - credentialPath: string; - inactiveSecretPaths?: string[]; - getConfiguredCredentialValue?: unknown; - setConfiguredCredentialValue?: unknown; - applySelectionConfig?: unknown; - resolveRuntimeMetadata?: unknown; - }; - }) { - return { - pluginId: params.pluginId, - id: params.provider.id, - label: params.provider.label, - hint: params.provider.hint, - envVars: params.provider.envVars, - placeholder: params.provider.placeholder, - signupUrl: params.provider.signupUrl, - docsUrl: params.provider.docsUrl, - autoDetectOrder: params.provider.autoDetectOrder, - requiresCredential: params.provider.requiresCredential, - credentialPath: params.provider.credentialPath, - inactiveSecretPaths: params.provider.inactiveSecretPaths, - hasConfiguredCredentialAccessors: - typeof params.provider.getConfiguredCredentialValue === "function" && - typeof params.provider.setConfiguredCredentialValue === "function", - hasApplySelectionConfig: typeof params.provider.applySelectionConfig === "function", - hasResolveRuntimeMetadata: typeof params.provider.resolveRuntimeMetadata === "function", - }; - } - - function sortComparableEntries< - T extends { - autoDetectOrder?: number; - id: string; - pluginId: string; - }, - >(entries: T[]): T[] { - return [...entries].toSorted((left, right) => { - const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - return ( - leftOrder - rightOrder || - left.id.localeCompare(right.id) || - left.pluginId.localeCompare(right.pluginId) - ); - }); - } - it("keeps bundled web search compat ids aligned with bundled manifests", () => { - expect(resolveBundledWebSearchPluginIds({})).toEqual([ - "brave", - "duckduckgo", - "exa", - "firecrawl", - "google", - "moonshot", - "perplexity", - "tavily", - "xai", - ]); + const bundledWebSearchPluginIds = loadPluginManifestRegistry({}) + .plugins.filter( + (plugin) => + plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0, + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); + + expect(resolveBundledWebSearchPluginIds({})).toEqual(bundledWebSearchPluginIds); }); it("keeps bundled web search fast-path ids aligned with the registry", () => { @@ -90,138 +27,4 @@ describe("bundled web search metadata", () => { .toSorted((left, right) => left.localeCompare(right)), ); }); - - it("keeps bundled web search provider-to-plugin ids aligned with bundled contracts", () => { - expect(resolveBundledWebSearchPluginId("brave")).toBe("brave"); - expect(resolveBundledWebSearchPluginId("duckduckgo")).toBe("duckduckgo"); - expect(resolveBundledWebSearchPluginId("exa")).toBe("exa"); - expect(resolveBundledWebSearchPluginId("firecrawl")).toBe("firecrawl"); - expect(resolveBundledWebSearchPluginId("gemini")).toBe("google"); - expect(resolveBundledWebSearchPluginId("kimi")).toBe("moonshot"); - expect(resolveBundledWebSearchPluginId("perplexity")).toBe("perplexity"); - expect(resolveBundledWebSearchPluginId("tavily")).toBe("tavily"); - expect(resolveBundledWebSearchPluginId("grok")).toBe("xai"); - }); - - it("keeps bundled provider metadata aligned with bundled plugin contracts", async () => { - const fastPathProviders = listBundledWebSearchProviders(); - const bundledProviderEntries = loadBundledCapabilityRuntimeRegistry({ - pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS, - pluginSdkResolution: "dist", - }).webSearchProviders.map((entry) => ({ - pluginId: entry.pluginId, - ...entry.provider, - })); - - expect( - sortComparableEntries( - fastPathProviders.map((provider) => - toComparableEntry({ - pluginId: provider.pluginId, - provider, - }), - ), - ), - ).toEqual( - sortComparableEntries( - bundledProviderEntries.map(({ pluginId, ...provider }) => - toComparableEntry({ - pluginId, - provider, - }), - ), - ), - ); - - for (const fastPathProvider of fastPathProviders) { - const bundledEntry = bundledProviderEntries.find( - (entry) => entry.pluginId === fastPathProvider.pluginId && entry.id === fastPathProvider.id, - ); - expect(bundledEntry).toBeDefined(); - const contractProvider = bundledEntry!; - - const fastSearchConfig: Record = {}; - const contractSearchConfig: Record = {}; - fastPathProvider.setCredentialValue(fastSearchConfig, "test-key"); - contractProvider.setCredentialValue(contractSearchConfig, "test-key"); - expect(fastSearchConfig).toEqual(contractSearchConfig); - expect(fastPathProvider.getCredentialValue(fastSearchConfig)).toEqual( - contractProvider.getCredentialValue(contractSearchConfig), - ); - - const fastConfig = {} as OpenClawConfig; - const contractConfig = {} as OpenClawConfig; - fastPathProvider.setConfiguredCredentialValue?.(fastConfig, "test-key"); - contractProvider.setConfiguredCredentialValue?.(contractConfig, "test-key"); - expect(fastConfig).toEqual(contractConfig); - expect(fastPathProvider.getConfiguredCredentialValue?.(fastConfig)).toEqual( - contractProvider.getConfiguredCredentialValue?.(contractConfig), - ); - - if (fastPathProvider.applySelectionConfig || contractProvider.applySelectionConfig) { - expect(fastPathProvider.applySelectionConfig?.({} as OpenClawConfig)).toEqual( - contractProvider.applySelectionConfig?.({} as OpenClawConfig), - ); - } - - if (fastPathProvider.resolveRuntimeMetadata || contractProvider.resolveRuntimeMetadata) { - const metadataCases = [ - { - searchConfig: fastSearchConfig, - resolvedCredential: { - value: "pplx-test", - source: "secretRef" as const, - fallbackEnvVar: undefined, - }, - }, - { - searchConfig: fastSearchConfig, - resolvedCredential: { - value: undefined, - source: "env" as const, - fallbackEnvVar: "OPENROUTER_API_KEY", - }, - }, - { - searchConfig: { - ...fastSearchConfig, - perplexity: { - ...(fastSearchConfig.perplexity as Record | undefined), - model: "custom-model", - }, - }, - resolvedCredential: { - value: "pplx-test", - source: "secretRef" as const, - fallbackEnvVar: undefined, - }, - }, - ]; - - for (const testCase of metadataCases) { - expect( - await fastPathProvider.resolveRuntimeMetadata?.({ - config: fastConfig, - searchConfig: testCase.searchConfig, - runtimeMetadata: { - diagnostics: [], - providerSource: "configured", - }, - resolvedCredential: testCase.resolvedCredential, - }), - ).toEqual( - await contractProvider.resolveRuntimeMetadata?.({ - config: contractConfig, - searchConfig: testCase.searchConfig, - runtimeMetadata: { - diagnostics: [], - providerSource: "configured", - }, - resolvedCredential: testCase.resolvedCredential, - }), - ); - } - } - } - }); }); diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index c5e4f4168f9..0b5c780a71e 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -16,12 +16,12 @@ import { resolveGatewayStartupPluginIds } from "./channel-plugin-ids.js"; describe("resolveGatewayStartupPluginIds", () => { beforeEach(() => { - listPotentialConfiguredChannelIds.mockReset().mockReturnValue(["discord"]); + listPotentialConfiguredChannelIds.mockReset().mockReturnValue(["demo-channel"]); loadPluginManifestRegistry.mockReset().mockReturnValue({ plugins: [ { - id: "discord", - channels: ["discord"], + id: "demo-channel", + channels: ["demo-channel"], origin: "bundled", enabledByDefault: undefined, providers: [], @@ -36,12 +36,12 @@ describe("resolveGatewayStartupPluginIds", () => { cliBackends: [], }, { - id: "anthropic", + id: "demo-provider-plugin", channels: [], origin: "bundled", enabledByDefault: undefined, - providers: ["anthropic"], - cliBackends: ["claude-cli"], + providers: ["demo-provider"], + cliBackends: ["demo-cli"], }, { id: "diagnostics-otel", @@ -73,9 +73,9 @@ describe("resolveGatewayStartupPluginIds", () => { }, agents: { defaults: { - model: { primary: "claude-cli/claude-sonnet-4-6" }, + model: { primary: "demo-cli/demo-model" }, models: { - "claude-cli/claude-sonnet-4-6": {}, + "demo-cli/demo-model": {}, }, }, }, @@ -87,7 +87,7 @@ describe("resolveGatewayStartupPluginIds", () => { workspaceDir: "/tmp", env: process.env, }), - ).toEqual(["discord", "anthropic", "diagnostics-otel", "custom-sidecar"]); + ).toEqual(["demo-channel", "demo-provider-plugin", "diagnostics-otel", "custom-sidecar"]); }); it("does not pull default-on bundled non-channel plugins into startup", () => { @@ -99,14 +99,14 @@ describe("resolveGatewayStartupPluginIds", () => { workspaceDir: "/tmp", env: process.env, }), - ).toEqual(["discord", "custom-sidecar"]); + ).toEqual(["demo-channel", "custom-sidecar"]); }); it("auto-loads bundled plugins referenced by configured provider ids", () => { const config = { models: { providers: { - anthropic: { + "demo-provider": { baseUrl: "https://example.com", models: [], }, @@ -120,6 +120,6 @@ describe("resolveGatewayStartupPluginIds", () => { workspaceDir: "/tmp", env: process.env, }), - ).toEqual(["discord", "anthropic", "custom-sidecar"]); + ).toEqual(["demo-channel", "demo-provider-plugin", "custom-sidecar"]); }); }); diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index 5fdf4633fd3..c7eac968c2b 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -58,8 +58,8 @@ describe("provider auth-choice contract", () => { it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => { const pluginFallbackScenarios: ProviderPlugin[] = [ { - id: "github-copilot", - label: "GitHub Copilot", + id: "demo-oauth-provider", + label: "Demo OAuth Provider", auth: [ { id: "oauth", @@ -71,8 +71,8 @@ describe("provider auth-choice contract", () => { ], }, { - id: "minimax-portal", - label: "MiniMax Portal", + id: "demo-browser-provider", + label: "Demo Browser Provider", auth: [ { id: "portal", @@ -84,8 +84,8 @@ describe("provider auth-choice contract", () => { ], }, { - id: "modelstudio", - label: "ModelStudio", + id: "demo-api-key-provider", + label: "Demo API Key Provider", auth: [ { id: "api-key", @@ -97,8 +97,8 @@ describe("provider auth-choice contract", () => { ], }, { - id: "ollama", - label: "Ollama", + id: "demo-local-provider", + label: "Demo Local Provider", auth: [ { id: "local", diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts deleted file mode 100644 index f740f853613..00000000000 --- a/src/plugins/contracts/auth.contract.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; -import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; -import { createNonExitingRuntime } from "../../runtime.js"; -import type { - WizardMultiSelectParams, - WizardPrompter, - WizardProgress, - WizardSelectParams, -} from "../../wizard/prompts.js"; -import { registerProviders, requireProvider } from "./testkit.js"; - -type LoginOpenAICodexOAuth = - (typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"]; -type GithubCopilotLoginCommand = - (typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"]; -type CreateVpsAwareHandlers = - (typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; -type EnsureAuthProfileStore = - typeof import("openclaw/plugin-sdk/agent-runtime").ensureAuthProfileStore; -type ListProfilesForProvider = - typeof import("openclaw/plugin-sdk/agent-runtime").listProfilesForProvider; - -const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn()); -const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); -const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); -const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); - -vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, - githubCopilotLoginCommand: githubCopilotLoginCommandMock, - }; -}); - -vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - ensureAuthProfileStore: ensureAuthProfileStoreMock, - listProfilesForProvider: listProfilesForProviderMock, - }; -}); - -import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; -import openAIPlugin from "../../../extensions/openai/index.js"; - -function buildPrompter(): WizardPrompter { - const progress: WizardProgress = { - update() {}, - stop() {}, - }; - return { - intro: async () => {}, - outro: async () => {}, - note: async () => {}, - select: async (params: WizardSelectParams) => { - const option = params.options[0]; - if (!option) { - throw new Error("missing select option"); - } - return option.value; - }, - multiselect: async (params: WizardMultiSelectParams) => params.initialValues ?? [], - text: async () => "", - confirm: async () => false, - progress: () => progress, - }; -} - -function buildAuthContext() { - return { - config: {}, - prompter: buildPrompter(), - runtime: createNonExitingRuntime(), - isRemote: false, - openUrl: async () => {}, - oauth: { - createVpsAwareHandlers: vi.fn(), - }, - }; -} - -function createJwt(payload: Record): string { - const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); - const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); - return `${header}.${body}.signature`; -} - -function getOpenAICodexProvider() { - return requireProvider(registerProviders(openAIPlugin), "openai-codex"); -} - -function buildOpenAICodexOAuthResult(params: { - profileId: string; - access: string; - refresh: string; - expires: number; - email?: string; -}) { - return { - profiles: [ - { - profileId: params.profileId, - credential: { - type: "oauth" as const, - provider: "openai-codex", - access: params.access, - refresh: params.refresh, - expires: params.expires, - ...(params.email ? { email: params.email } : {}), - }, - }, - ], - configPatch: { - agents: { - defaults: { - models: { - "openai-codex/gpt-5.4": {}, - }, - }, - }, - }, - defaultModel: "openai-codex/gpt-5.4", - notes: undefined, - }; -} - -async function expectOpenAICodexStableFallbackProfile(params: { - access: string; - profileId: string; -}) { - const provider = getOpenAICodexProvider(); - loginOpenAICodexOAuthMock.mockResolvedValueOnce({ - refresh: "refresh-token", - access: params.access, - expires: 1_700_000_000_000, - }); - const result = await provider.auth[0]?.run(buildAuthContext() as never); - expect(result).toEqual( - buildOpenAICodexOAuthResult({ - profileId: params.profileId, - access: params.access, - refresh: "refresh-token", - expires: 1_700_000_000_000, - }), - ); -} - -describe("provider auth contract", () => { - let authStore: AuthProfileStore; - - beforeEach(() => { - authStore = { version: 1, profiles: {} }; - ensureAuthProfileStoreMock.mockReset(); - ensureAuthProfileStoreMock.mockImplementation(() => authStore); - listProfilesForProviderMock.mockReset(); - listProfilesForProviderMock.mockImplementation((store, providerId) => - Object.entries(store.profiles) - .filter(([, credential]) => credential?.provider === providerId) - .map(([profileId]) => profileId), - ); - }); - - afterEach(() => { - loginOpenAICodexOAuthMock.mockReset(); - githubCopilotLoginCommandMock.mockReset(); - ensureAuthProfileStoreMock.mockReset(); - listProfilesForProviderMock.mockReset(); - clearRuntimeAuthProfileStoreSnapshots(); - }); - - it("keeps OpenAI Codex OAuth auth results provider-owned", async () => { - const provider = getOpenAICodexProvider(); - loginOpenAICodexOAuthMock.mockResolvedValueOnce({ - email: "user@example.com", - refresh: "refresh-token", - access: "access-token", - expires: 1_700_000_000_000, - }); - - const result = await provider.auth[0]?.run(buildAuthContext() as never); - - expect(result).toEqual( - buildOpenAICodexOAuthResult({ - profileId: "openai-codex:user@example.com", - access: "access-token", - refresh: "refresh-token", - expires: 1_700_000_000_000, - email: "user@example.com", - }), - ); - }); - - it("backfills OpenAI Codex OAuth email from the JWT profile claim", async () => { - const provider = getOpenAICodexProvider(); - const access = createJwt({ - "https://api.openai.com/profile": { - email: "jwt-user@example.com", - }, - }); - loginOpenAICodexOAuthMock.mockResolvedValueOnce({ - refresh: "refresh-token", - access, - expires: 1_700_000_000_000, - }); - - const result = await provider.auth[0]?.run(buildAuthContext() as never); - - expect(result).toEqual( - buildOpenAICodexOAuthResult({ - profileId: "openai-codex:jwt-user@example.com", - access, - refresh: "refresh-token", - expires: 1_700_000_000_000, - email: "jwt-user@example.com", - }), - ); - }); - - it("uses a stable fallback id when OpenAI Codex JWT email is missing", async () => { - const access = createJwt({ - "https://api.openai.com/auth": { - chatgpt_account_user_id: "user-123__acct-456", - }, - }); - const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url"); - await expectOpenAICodexStableFallbackProfile({ - access, - profileId: `openai-codex:id-${expectedStableId}`, - }); - }); - - it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => { - const access = createJwt({ - iss: "https://accounts.openai.com", - sub: "user-abc", - }); - const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString( - "base64url", - ); - await expectOpenAICodexStableFallbackProfile({ - access, - profileId: `openai-codex:id-${expectedStableId}`, - }); - }); - - it("uses sub alone to build a stable fallback id when iss is missing", async () => { - const access = createJwt({ - sub: "user-abc", - }); - const expectedStableId = Buffer.from("user-abc").toString("base64url"); - await expectOpenAICodexStableFallbackProfile({ - access, - profileId: `openai-codex:id-${expectedStableId}`, - }); - }); - - it("falls back to the default OpenAI Codex profile when JWT parsing yields no identity", async () => { - const provider = getOpenAICodexProvider(); - loginOpenAICodexOAuthMock.mockResolvedValueOnce({ - refresh: "refresh-token", - access: "not-a-jwt-token", - expires: 1_700_000_000_000, - }); - - const result = await provider.auth[0]?.run(buildAuthContext() as never); - - expect(result).toEqual( - buildOpenAICodexOAuthResult({ - profileId: "openai-codex:default", - access: "not-a-jwt-token", - refresh: "refresh-token", - expires: 1_700_000_000_000, - }), - ); - }); - - it("keeps OpenAI Codex OAuth failures non-fatal at the provider layer", async () => { - const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); - loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed")); - - await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({ - profiles: [], - }); - }); - - it("keeps GitHub Copilot device auth results provider-owned", async () => { - const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); - authStore.profiles["github-copilot:github"] = { - type: "token" as const, - provider: "github-copilot", - token: "github-device-token", - }; - - const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; - const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); - const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); - Object.defineProperty(stdin, "isTTY", { - configurable: true, - enumerable: true, - get: () => true, - }); - - try { - const result = await provider.auth[0]?.run(buildAuthContext() as never); - expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith( - { yes: true, profileId: "github-copilot:github" }, - expect.any(Object), - ); - expect(result).toEqual({ - profiles: [ - { - profileId: "github-copilot:github", - credential: { - type: "token", - provider: "github-copilot", - token: "github-device-token", - }, - }, - ], - defaultModel: "github-copilot/gpt-4o", - }); - } finally { - if (previousIsTTYDescriptor) { - Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); - } else if (!hadOwnIsTTY) { - delete (stdin as { isTTY?: boolean }).isTTY; - } - } - }); - - it("keeps GitHub Copilot auth gated on interactive TTYs", async () => { - const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); - const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; - const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); - const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); - Object.defineProperty(stdin, "isTTY", { - configurable: true, - enumerable: true, - get: () => false, - }); - - try { - await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({ - profiles: [], - }); - expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); - } finally { - if (previousIsTTYDescriptor) { - Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); - } else if (!hadOwnIsTTY) { - delete (stdin as { isTTY?: boolean }).isTTY; - } - } - }); -}); diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts deleted file mode 100644 index adf376bb0d1..00000000000 --- a/src/plugins/contracts/catalog.contract.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { beforeAll, beforeEach, describe, it, vi } from "vitest"; -import { - registerProviderPlugin, - requireRegisteredProvider, -} from "../../../test/helpers/extensions/provider-registration.js"; -import { - expectAugmentedCodexCatalog, - expectCodexBuiltInSuppression, - expectCodexMissingAuthHint, -} from "../provider-runtime.test-support.js"; -import type { ProviderPlugin } from "../types.js"; - -const PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS = 300_000; - -type ResolvePluginProviders = typeof import("../providers.runtime.js").resolvePluginProviders; -type ResolveOwningPluginIdsForProvider = - typeof import("../providers.js").resolveOwningPluginIdsForProvider; -type ResolveCatalogHookProviderPluginIds = - typeof import("../providers.js").resolveCatalogHookProviderPluginIds; - -const resolvePluginProvidersMock = vi.hoisted(() => vi.fn(() => [])); -const resolveOwningPluginIdsForProviderMock = vi.hoisted(() => - vi.fn(() => undefined), -); -const resolveCatalogHookProviderPluginIdsMock = vi.hoisted(() => - vi.fn((_) => [] as string[]), -); - -vi.mock("../providers.js", () => ({ - resolveOwningPluginIdsForProvider: (params: unknown) => - resolveOwningPluginIdsForProviderMock(params as never), - resolveCatalogHookProviderPluginIds: (params: unknown) => - resolveCatalogHookProviderPluginIdsMock(params as never), -})); - -vi.mock("../providers.runtime.js", () => ({ - resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), -})); - -let augmentModelCatalogWithProviderPlugins: typeof import("../provider-runtime.js").augmentModelCatalogWithProviderPlugins; -let resetProviderRuntimeHookCacheForTest: typeof import("../provider-runtime.js").resetProviderRuntimeHookCacheForTest; -let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.js").resolveProviderBuiltInModelSuppression; -let openaiProviders: ProviderPlugin[]; -let openaiProvider: ProviderPlugin; - -describe("provider catalog contract", { timeout: PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS }, () => { - beforeAll(async () => { - vi.resetModules(); - const openaiPlugin = await import("../../../extensions/openai/index.ts"); - openaiProviders = registerProviderPlugin({ - plugin: openaiPlugin.default, - id: "openai", - name: "OpenAI", - }).providers; - openaiProvider = requireRegisteredProvider(openaiProviders, "openai", "provider"); - ({ - augmentModelCatalogWithProviderPlugins, - resetProviderRuntimeHookCacheForTest, - resolveProviderBuiltInModelSuppression, - } = await import("../provider-runtime.js")); - }); - - beforeEach(() => { - resetProviderRuntimeHookCacheForTest(); - - resolvePluginProvidersMock.mockReset(); - resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { - const onlyPluginIds = params?.onlyPluginIds; - if (!onlyPluginIds || onlyPluginIds.length === 0) { - return openaiProviders; - } - return onlyPluginIds.includes("openai") ? openaiProviders : []; - }); - - resolveOwningPluginIdsForProviderMock.mockReset(); - resolveOwningPluginIdsForProviderMock.mockImplementation((params) => { - switch (params.provider) { - case "azure-openai-responses": - case "openai": - case "openai-codex": - return ["openai"]; - default: - return undefined; - } - }); - - resolveCatalogHookProviderPluginIdsMock.mockReset(); - resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]); - }); - - it("keeps codex-only missing-auth hints wired through the provider runtime", () => { - expectCodexMissingAuthHint( - (params) => openaiProvider.buildMissingAuthMessage?.(params.context) ?? undefined, - ); - }); - - it("keeps built-in model suppression wired through the provider runtime", () => { - expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression); - }); - - it("keeps bundled model augmentation wired through the provider runtime", async () => { - await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins); - }); -}); diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts deleted file mode 100644 index 664efbe4ec4..00000000000 --- a/src/plugins/contracts/discovery.contract.test.ts +++ /dev/null @@ -1,572 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; -import type { ModelDefinitionConfig } from "../../config/types.models.js"; -import { registerProviders, requireProvider } from "./testkit.js"; - -const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); -const buildOllamaProviderMock = vi.hoisted(() => vi.fn()); -const buildVllmProviderMock = vi.hoisted(() => vi.fn()); -const buildSglangProviderMock = vi.hoisted(() => vi.fn()); -const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); -const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); - -let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCatalog; -let githubCopilotProvider: Awaited>; -let ollamaProvider: Awaited>; -let vllmProvider: Awaited>; -let sglangProvider: Awaited>; -let minimaxProvider: Awaited>; -let minimaxPortalProvider: Awaited>; -let modelStudioProvider: Awaited>; -let cloudflareAiGatewayProvider: Awaited>; - -function createModelConfig(id: string, name = id): ModelDefinitionConfig { - return { - id, - name, - reasoning: false, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128_000, - maxTokens: 8_192, - }; -} - -function setRuntimeAuthStore(store?: AuthProfileStore) { - const resolvedStore = store ?? { - version: 1, - profiles: {}, - }; - ensureAuthProfileStoreMock.mockReturnValue(resolvedStore); - listProfilesForProviderMock.mockImplementation( - (authStore: AuthProfileStore, providerId: string) => - Object.entries(authStore.profiles) - .filter(([, credential]) => credential.provider === providerId) - .map(([profileId]) => profileId), - ); -} - -function setGithubCopilotProfileSnapshot() { - setRuntimeAuthStore({ - version: 1, - profiles: { - "github-copilot:github": { - type: "token", - provider: "github-copilot", - token: "profile-token", - }, - }, - }); -} - -function runCatalog(params: { - provider: Awaited>; - env?: NodeJS.ProcessEnv; - resolveProviderApiKey?: () => { apiKey: string | undefined }; - resolveProviderAuth?: ( - providerId?: string, - options?: { oauthMarker?: string }, - ) => { - apiKey: string | undefined; - discoveryApiKey?: string; - mode: "api_key" | "oauth" | "token" | "none"; - source: "env" | "profile" | "none"; - profileId?: string; - }; -}) { - return runProviderCatalog({ - provider: params.provider, - config: {}, - env: params.env ?? ({} as NodeJS.ProcessEnv), - resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })), - resolveProviderAuth: - params.resolveProviderAuth ?? - ((_, options) => ({ - apiKey: options?.oauthMarker, - discoveryApiKey: undefined, - mode: options?.oauthMarker ? "oauth" : "none", - source: options?.oauthMarker ? "profile" : "none", - })), - }); -} - -describe("provider discovery contract", () => { - beforeEach(async () => { - vi.resetModules(); - vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => { - // Import the direct source module, not the mocked subpath, so bundled - // provider helpers still see the full agent-runtime surface. - const actual = await import("../../plugin-sdk/agent-runtime.ts"); - return { - ...actual, - ensureAuthProfileStore: ensureAuthProfileStoreMock, - listProfilesForProvider: listProfilesForProviderMock, - }; - }); - vi.doMock("openclaw/plugin-sdk/provider-auth", async () => { - const actual = await vi.importActual("openclaw/plugin-sdk/provider-auth"); - return { - ...actual, - ensureAuthProfileStore: ensureAuthProfileStoreMock, - listProfilesForProvider: listProfilesForProviderMock, - }; - }); - vi.doMock("../../../extensions/github-copilot/token.js", async () => { - const actual = await vi.importActual("../../../extensions/github-copilot/token.js"); - return { - ...actual, - resolveCopilotApiToken: resolveCopilotApiTokenMock, - }; - }); - vi.doMock("openclaw/plugin-sdk/provider-setup", async () => { - const actual = await vi.importActual("openclaw/plugin-sdk/provider-setup"); - return { - ...actual, - buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), - buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), - buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), - }; - }); - vi.doMock("openclaw/plugin-sdk/self-hosted-provider-setup", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/self-hosted-provider-setup", - ); - return { - ...actual, - buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), - buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), - }; - }); - ({ runProviderCatalog } = await import("../provider-discovery.js")); - const [ - { default: githubCopilotPlugin }, - { default: ollamaPlugin }, - { default: vllmPlugin }, - { default: sglangPlugin }, - { default: minimaxPlugin }, - { default: modelStudioPlugin }, - { default: cloudflareAiGatewayPlugin }, - ] = await Promise.all([ - import("../../../extensions/github-copilot/index.js"), - import("../../../extensions/ollama/index.js"), - import("../../../extensions/vllm/index.js"), - import("../../../extensions/sglang/index.js"), - import("../../../extensions/minimax/index.js"), - import("../../../extensions/modelstudio/index.js"), - import("../../../extensions/cloudflare-ai-gateway/index.js"), - ]); - githubCopilotProvider = requireProvider( - registerProviders(githubCopilotPlugin), - "github-copilot", - ); - ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama"); - vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm"); - sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang"); - minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax"); - minimaxPortalProvider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal"); - modelStudioProvider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio"); - cloudflareAiGatewayProvider = requireProvider( - registerProviders(cloudflareAiGatewayPlugin), - "cloudflare-ai-gateway", - ); - setRuntimeAuthStore(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - resolveCopilotApiTokenMock.mockReset(); - buildOllamaProviderMock.mockReset(); - buildVllmProviderMock.mockReset(); - buildSglangProviderMock.mockReset(); - ensureAuthProfileStoreMock.mockReset(); - listProfilesForProviderMock.mockReset(); - }); - - it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => { - await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull(); - }); - - it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => { - setGithubCopilotProfileSnapshot(); - - await expect( - runCatalog({ - provider: githubCopilotProvider, - }), - ).resolves.toEqual({ - provider: { - baseUrl: "https://api.individual.githubcopilot.com", - models: [], - }, - }); - }); - - it("keeps GitHub Copilot env-token base URL resolution provider-owned", async () => { - resolveCopilotApiTokenMock.mockResolvedValueOnce({ - token: "copilot-api-token", - baseUrl: "https://copilot-proxy.example.com", - expiresAt: Date.now() + 60_000, - }); - - await expect( - runCatalog({ - provider: githubCopilotProvider, - env: { - GITHUB_TOKEN: "github-env-token", - } as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - }), - ).resolves.toEqual({ - provider: { - baseUrl: "https://copilot-proxy.example.com", - models: [], - }, - }); - expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({ - githubToken: "github-env-token", - env: expect.objectContaining({ - GITHUB_TOKEN: "github-env-token", - }), - }); - }); - - 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")], - }, - }, - }, - }, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: undefined, - discoveryApiKey: undefined, - mode: "none", - source: "none", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "http://ollama-host:11434", - api: "ollama", - apiKey: "ollama-local", - models: [createModelConfig("llama3.2")], - }, - }); - expect(buildOllamaProviderMock).not.toHaveBeenCalled(); - }); - - it("keeps Ollama empty autodiscovery disabled without keys or explicit config", async () => { - buildOllamaProviderMock.mockResolvedValueOnce({ - baseUrl: "http://127.0.0.1:11434", - api: "ollama", - 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(); - 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: { - 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", - }), - }), - ).resolves.toEqual({ - provider: { - 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", - }); - }); - - it("keeps MiniMax API catalog provider-owned", async () => { - await expect( - runProviderCatalog({ - provider: minimaxProvider, - config: {}, - env: { - MINIMAX_API_KEY: "minimax-key", - } as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: "minimax-key" }), - resolveProviderAuth: () => ({ - apiKey: "minimax-key", - discoveryApiKey: undefined, - mode: "api_key", - source: "env", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - authHeader: true, - apiKey: "minimax-key", - models: expect.arrayContaining([ - expect.objectContaining({ id: "MiniMax-M2.7" }), - expect.objectContaining({ id: "MiniMax-M2.7-highspeed" }), - ]), - }, - }); - }); - - it("keeps MiniMax portal oauth marker fallback provider-owned", async () => { - setRuntimeAuthStore({ - version: 1, - profiles: { - "minimax-portal:default": { - type: "oauth", - provider: "minimax-portal", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - }, - }); - - await expect( - runProviderCatalog({ - provider: minimaxPortalProvider, - config: {}, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: "minimax-oauth", - discoveryApiKey: "access-token", - mode: "oauth", - source: "profile", - profileId: "minimax-portal:default", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "https://api.minimax.io/anthropic", - api: "anthropic-messages", - authHeader: true, - apiKey: "minimax-oauth", - models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.7" })]), - }, - }); - }); - - it("keeps MiniMax portal explicit base URL override provider-owned", async () => { - await expect( - runProviderCatalog({ - provider: minimaxPortalProvider, - config: { - models: { - providers: { - "minimax-portal": { - baseUrl: "https://portal-proxy.example.com/anthropic", - apiKey: "explicit-key", - models: [], - }, - }, - }, - }, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: undefined, - discoveryApiKey: undefined, - mode: "none", - source: "none", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "https://portal-proxy.example.com/anthropic", - apiKey: "explicit-key", - }, - }); - }); - - it("keeps Model Studio catalog provider-owned", async () => { - await expect( - runProviderCatalog({ - provider: modelStudioProvider, - config: { - models: { - providers: { - modelstudio: { - baseUrl: "https://coding.dashscope.aliyuncs.com/v1", - models: [], - }, - }, - }, - }, - env: { - MODELSTUDIO_API_KEY: "modelstudio-key", - } as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }), - resolveProviderAuth: () => ({ - apiKey: "modelstudio-key", - discoveryApiKey: undefined, - mode: "api_key", - source: "env", - }), - }), - ).resolves.toMatchObject({ - provider: { - baseUrl: "https://coding.dashscope.aliyuncs.com/v1", - api: "openai-completions", - apiKey: "modelstudio-key", - models: expect.arrayContaining([ - expect.objectContaining({ id: "qwen3.5-plus" }), - expect.objectContaining({ id: "MiniMax-M2.5" }), - ]), - }, - }); - }); - - it("keeps Cloudflare AI Gateway catalog disabled without stored metadata", async () => { - await expect( - runProviderCatalog({ - provider: cloudflareAiGatewayProvider, - config: {}, - env: {} as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: undefined, - discoveryApiKey: undefined, - mode: "none", - source: "none", - }), - }), - ).resolves.toBeNull(); - }); - - it("keeps Cloudflare AI Gateway env-managed catalog provider-owned", async () => { - setRuntimeAuthStore({ - version: 1, - profiles: { - "cloudflare-ai-gateway:default": { - type: "api_key", - provider: "cloudflare-ai-gateway", - keyRef: { - source: "env", - provider: "default", - id: "CLOUDFLARE_AI_GATEWAY_API_KEY", - }, - metadata: { - accountId: "acc-123", - gatewayId: "gw-456", - }, - }, - }, - }); - - await expect( - runProviderCatalog({ - provider: cloudflareAiGatewayProvider, - config: {}, - env: { - CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value", - } as NodeJS.ProcessEnv, - resolveProviderApiKey: () => ({ apiKey: undefined }), - resolveProviderAuth: () => ({ - apiKey: undefined, - discoveryApiKey: undefined, - mode: "none", - source: "none", - }), - }), - ).resolves.toEqual({ - provider: { - baseUrl: "https://gateway.ai.cloudflare.com/v1/acc-123/gw-456/anthropic", - api: "anthropic-messages", - apiKey: "CLOUDFLARE_AI_GATEWAY_API_KEY", - models: [expect.objectContaining({ id: "claude-sonnet-4-5" })], - }, - }); - }); -}); diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index ac4b8d363e6..a8bba1e0b22 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -15,6 +15,8 @@ function resolveBundledManifestProviderPluginIds() { ); } +const demoAllowEntry = "demo-allowed"; + describe("plugin loader contract", () => { let providerPluginIds: string[]; let manifestProviderPluginIds: string[]; @@ -31,14 +33,14 @@ describe("plugin loader contract", () => { compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({ config: { plugins: { - allow: ["openrouter"], + allow: [demoAllowEntry], }, }, }); compatConfig = withBundledPluginAllowlistCompat({ config: { plugins: { - allow: ["openrouter"], + allow: [demoAllowEntry], }, }, pluginIds: compatPluginIds, @@ -55,7 +57,7 @@ describe("plugin loader contract", () => { webSearchAllowlistCompatConfig = withBundledPluginAllowlistCompat({ config: { plugins: { - allow: ["openrouter"], + allow: [demoAllowEntry], }, }, pluginIds: webSearchPluginIds, diff --git a/src/plugins/contracts/memory-embedding-provider.contract.test.ts b/src/plugins/contracts/memory-embedding-provider.contract.test.ts index d35339bc203..437aeb75ed4 100644 --- a/src/plugins/contracts/memory-embedding-provider.contract.test.ts +++ b/src/plugins/contracts/memory-embedding-provider.contract.test.ts @@ -83,14 +83,14 @@ describe("memory embedding provider registration", () => { }), register(api) { api.registerMemoryEmbeddingProvider({ - id: "openai", + id: "demo-embedding", create: async () => ({ provider: null }), }); }, }); - expect(getRegisteredMemoryEmbeddingProvider("openai")).toEqual({ - adapter: expect.objectContaining({ id: "openai" }), + expect(getRegisteredMemoryEmbeddingProvider("demo-embedding")).toEqual({ + adapter: expect.objectContaining({ id: "demo-embedding" }), ownerPluginId: "memory-core", }); }); diff --git a/src/plugins/contracts/provider.contract.test.ts b/src/plugins/contracts/provider.contract.test.ts deleted file mode 100644 index db5ce6e3c03..00000000000 --- a/src/plugins/contracts/provider.contract.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { providerContractLoadError, providerContractRegistry } from "./registry.js"; -import { installProviderPluginContractSuite } from "./suites.js"; - -describe("provider contract registry load", () => { - it("loads bundled providers without import-time registry failure", () => { - expect(providerContractLoadError).toBeUndefined(); - expect(providerContractRegistry.length).toBeGreaterThan(0); - }); -}); - -for (const entry of providerContractRegistry) { - describe(`${entry.pluginId}:${entry.provider.id} provider contract`, () => { - installProviderPluginContractSuite({ - provider: entry.provider, - }); - }); -} diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index f39d05e4a82..a7ded06ba15 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -12,93 +12,6 @@ import { const REGISTRY_CONTRACT_TIMEOUT_MS = 300_000; -function findProviderIdsForPlugin(pluginId: string) { - return ( - pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ?? - [] - ); -} - -function findWebSearchIdsForPlugin(pluginId: string) { - return ( - pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId) - ?.webSearchProviderIds ?? [] - ); -} - -function findSpeechProviderIdsForPlugin(pluginId: string) { - return speechProviderContractRegistry - .filter((entry) => entry.pluginId === pluginId) - .map((entry) => entry.provider.id) - .toSorted((left, right) => left.localeCompare(right)); -} - -function findSpeechProviderForPlugin(pluginId: string) { - const entry = speechProviderContractRegistry.find((candidate) => candidate.pluginId === pluginId); - if (!entry) { - throw new Error(`speech provider contract missing for ${pluginId}`); - } - return entry.provider; -} - -function findMediaUnderstandingProviderIdsForPlugin(pluginId: string) { - return mediaUnderstandingProviderContractRegistry - .filter((entry) => entry.pluginId === pluginId) - .map((entry) => entry.provider.id) - .toSorted((left, right) => left.localeCompare(right)); -} - -function findMediaUnderstandingProviderForPlugin(pluginId: string) { - const entry = mediaUnderstandingProviderContractRegistry.find( - (candidate) => candidate.pluginId === pluginId, - ); - if (!entry) { - throw new Error(`media-understanding provider contract missing for ${pluginId}`); - } - return entry.provider; -} - -function findImageGenerationProviderIdsForPlugin(pluginId: string) { - return imageGenerationProviderContractRegistry - .filter((entry) => entry.pluginId === pluginId) - .map((entry) => entry.provider.id) - .toSorted((left, right) => left.localeCompare(right)); -} - -function findImageGenerationProviderForPlugin(pluginId: string) { - const entry = imageGenerationProviderContractRegistry.find( - (candidate) => candidate.pluginId === pluginId, - ); - if (!entry) { - throw new Error(`image-generation provider contract missing for ${pluginId}`); - } - return entry.provider; -} - -function findRegistrationForPlugin(pluginId: string) { - const entry = pluginRegistrationContractRegistry.find( - (candidate) => candidate.pluginId === pluginId, - ); - if (!entry) { - throw new Error(`plugin registration contract missing for ${pluginId}`); - } - return entry; -} - -type BundledCapabilityContractKey = - | "speechProviders" - | "mediaUnderstandingProviders" - | "imageGenerationProviders"; - -function findBundledManifestPluginIdsForContract(key: BundledCapabilityContractKey) { - return loadPluginManifestRegistry({}) - .plugins.filter( - (plugin) => plugin.origin === "bundled" && (plugin.contracts?.[key]?.length ?? 0) > 0, - ) - .map((plugin) => plugin.id) - .toSorted((left, right) => left.localeCompare(right)); -} - describe("plugin contract registry", () => { it("loads bundled non-provider capability registries without import-time failure", () => { expect(providerContractLoadError).toBeUndefined(); @@ -139,7 +52,13 @@ describe("plugin contract registry", () => { }); it("covers every bundled speech plugin discovered from manifests", () => { - const bundledSpeechPluginIds = findBundledManifestPluginIdsForContract("speechProviders"); + 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( @@ -148,30 +67,6 @@ describe("plugin contract registry", () => { ).toEqual(bundledSpeechPluginIds); }); - it("covers every bundled media-understanding plugin discovered from manifests", () => { - const bundledMediaPluginIds = findBundledManifestPluginIdsForContract( - "mediaUnderstandingProviders", - ); - - expect( - [ - ...new Set(mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId)), - ].toSorted((left, right) => left.localeCompare(right)), - ).toEqual(bundledMediaPluginIds); - }); - - it("covers every bundled image-generation plugin discovered from manifests", () => { - const bundledImagePluginIds = findBundledManifestPluginIdsForContract( - "imageGenerationProviders", - ); - - expect( - [...new Set(imageGenerationProviderContractRegistry.map((entry) => entry.pluginId))].toSorted( - (left, right) => left.localeCompare(right), - ), - ).toEqual(bundledImagePluginIds); - }); - it("covers every bundled web search plugin from the shared resolver", () => { const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({}); @@ -183,222 +78,8 @@ describe("plugin contract registry", () => { ).toEqual(bundledWebSearchPluginIds); }); - it("keeps Kimi Coding onboarding grouped under Moonshot", () => { - const kimi = loadPluginManifestRegistry({}).plugins.find( - (plugin) => plugin.origin === "bundled" && plugin.id === "kimi", - ); - - expect(kimi?.providerAuthChoices).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - choiceId: "kimi-code-api-key", - choiceLabel: "Kimi Code API key (subscription)", - groupId: "moonshot", - groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5", - }), - ]), - ); - }); - it("does not duplicate bundled image-generation provider ids", () => { const ids = imageGenerationProviderContractRegistry.map((entry) => entry.provider.id); expect(ids).toEqual([...new Set(ids)]); }); - it("keeps multi-provider plugin ownership explicit", () => { - expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]); - expect(findProviderIdsForPlugin("minimax")).toEqual(["minimax", "minimax-portal"]); - expect(findProviderIdsForPlugin("openai")).toEqual(["openai", "openai-codex"]); - }); - - it("keeps bundled web search ownership explicit", () => { - expect(findWebSearchIdsForPlugin("brave")).toEqual(["brave"]); - expect(findWebSearchIdsForPlugin("duckduckgo")).toEqual(["duckduckgo"]); - expect(findWebSearchIdsForPlugin("exa")).toEqual(["exa"]); - expect(findWebSearchIdsForPlugin("firecrawl")).toEqual(["firecrawl"]); - expect(findWebSearchIdsForPlugin("google")).toEqual(["gemini"]); - expect(findWebSearchIdsForPlugin("moonshot")).toEqual(["kimi"]); - expect(findWebSearchIdsForPlugin("perplexity")).toEqual(["perplexity"]); - expect(findWebSearchIdsForPlugin("tavily")).toEqual(["tavily"]); - expect(findWebSearchIdsForPlugin("xai")).toEqual(["grok"]); - }); - - it("keeps bundled speech ownership explicit", () => { - expect(findSpeechProviderIdsForPlugin("elevenlabs")).toEqual(["elevenlabs"]); - expect(findSpeechProviderIdsForPlugin("microsoft")).toEqual(["microsoft"]); - expect(findSpeechProviderIdsForPlugin("openai")).toEqual(["openai"]); - }); - - it("keeps bundled media-understanding ownership explicit", () => { - expect(findMediaUnderstandingProviderIdsForPlugin("anthropic")).toEqual(["anthropic"]); - expect(findMediaUnderstandingProviderIdsForPlugin("google")).toEqual(["google"]); - expect(findMediaUnderstandingProviderIdsForPlugin("minimax")).toEqual([ - "minimax", - "minimax-portal", - ]); - expect(findMediaUnderstandingProviderIdsForPlugin("mistral")).toEqual(["mistral"]); - expect(findMediaUnderstandingProviderIdsForPlugin("moonshot")).toEqual(["moonshot"]); - expect(findMediaUnderstandingProviderIdsForPlugin("openai")).toEqual([ - "openai", - "openai-codex", - ]); - expect(findMediaUnderstandingProviderIdsForPlugin("zai")).toEqual(["zai"]); - }); - - it("keeps bundled image-generation ownership explicit", () => { - expect(findImageGenerationProviderIdsForPlugin("fal")).toEqual(["fal"]); - expect(findImageGenerationProviderIdsForPlugin("google")).toEqual(["google"]); - expect(findImageGenerationProviderIdsForPlugin("minimax")).toEqual([ - "minimax", - "minimax-portal", - ]); - expect(findImageGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]); - }); - - it("keeps bundled provider and web search tool ownership explicit", () => { - expect(findRegistrationForPlugin("exa")).toMatchObject({ - cliBackendIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: ["exa"], - toolNames: [], - }); - expect(findRegistrationForPlugin("firecrawl")).toMatchObject({ - cliBackendIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: ["firecrawl"], - toolNames: ["firecrawl_search", "firecrawl_scrape"], - }); - expect(findRegistrationForPlugin("tavily")).toMatchObject({ - cliBackendIds: [], - providerIds: [], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - webSearchProviderIds: ["tavily"], - toolNames: ["tavily_search", "tavily_extract"], - }); - }); - - it("tracks speech registrations on bundled provider plugins", () => { - expect(findRegistrationForPlugin("fal")).toMatchObject({ - cliBackendIds: [], - providerIds: ["fal"], - speechProviderIds: [], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: ["fal"], - webSearchProviderIds: [], - }); - expect(findRegistrationForPlugin("anthropic")).toMatchObject({ - cliBackendIds: ["claude-cli"], - providerIds: ["anthropic"], - speechProviderIds: [], - mediaUnderstandingProviderIds: ["anthropic"], - imageGenerationProviderIds: [], - webSearchProviderIds: [], - }); - expect(findRegistrationForPlugin("google")).toMatchObject({ - cliBackendIds: ["google-gemini-cli"], - providerIds: ["google", "google-gemini-cli"], - speechProviderIds: [], - mediaUnderstandingProviderIds: ["google"], - imageGenerationProviderIds: ["google"], - webSearchProviderIds: ["gemini"], - }); - expect(findRegistrationForPlugin("openai")).toMatchObject({ - cliBackendIds: ["codex-cli"], - providerIds: ["openai", "openai-codex"], - speechProviderIds: ["openai"], - mediaUnderstandingProviderIds: ["openai", "openai-codex"], - imageGenerationProviderIds: ["openai"], - }); - expect(findRegistrationForPlugin("minimax")).toMatchObject({ - cliBackendIds: [], - providerIds: ["minimax", "minimax-portal"], - speechProviderIds: [], - mediaUnderstandingProviderIds: ["minimax", "minimax-portal"], - imageGenerationProviderIds: ["minimax", "minimax-portal"], - webSearchProviderIds: [], - }); - expect(findRegistrationForPlugin("elevenlabs")).toMatchObject({ - cliBackendIds: [], - providerIds: [], - speechProviderIds: ["elevenlabs"], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - }); - expect(findRegistrationForPlugin("microsoft")).toMatchObject({ - cliBackendIds: [], - providerIds: [], - speechProviderIds: ["microsoft"], - mediaUnderstandingProviderIds: [], - imageGenerationProviderIds: [], - }); - }); - - it("tracks every provider, speech, media, image, or web search plugin in the registration registry", () => { - const expectedPluginIds = [ - ...new Set([ - ...pluginRegistrationContractRegistry - .filter((entry) => entry.providerIds.length > 0) - .map((entry) => entry.pluginId), - ...speechProviderContractRegistry.map((entry) => entry.pluginId), - ...mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId), - ...imageGenerationProviderContractRegistry.map((entry) => entry.pluginId), - ...pluginRegistrationContractRegistry - .filter((entry) => entry.webSearchProviderIds.length > 0) - .map((entry) => entry.pluginId), - ]), - ].toSorted((left, right) => left.localeCompare(right)); - - expect( - pluginRegistrationContractRegistry - .map((entry) => entry.pluginId) - .toSorted((left, right) => left.localeCompare(right)), - ).toEqual(expectedPluginIds); - }); - - it("keeps bundled speech voice-list support explicit", () => { - expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function)); - expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function)); - expect(findSpeechProviderForPlugin("microsoft").listVoices).toEqual(expect.any(Function)); - }); - - it("keeps bundled multi-image support explicit", () => { - expect(findMediaUnderstandingProviderForPlugin("anthropic").describeImages).toEqual( - expect.any(Function), - ); - expect(findMediaUnderstandingProviderForPlugin("google").describeImages).toEqual( - expect.any(Function), - ); - expect(findMediaUnderstandingProviderForPlugin("minimax").describeImages).toEqual( - expect.any(Function), - ); - expect(findMediaUnderstandingProviderForPlugin("moonshot").describeImages).toEqual( - expect.any(Function), - ); - expect(findMediaUnderstandingProviderForPlugin("openai").describeImages).toEqual( - expect.any(Function), - ); - expect(findMediaUnderstandingProviderForPlugin("zai").describeImages).toEqual( - expect.any(Function), - ); - }); - - it("keeps bundled image-generation support explicit", () => { - expect(findImageGenerationProviderForPlugin("google").generateImage).toEqual( - expect.any(Function), - ); - expect(findImageGenerationProviderForPlugin("minimax").generateImage).toEqual( - expect.any(Function), - ); - expect(findImageGenerationProviderForPlugin("openai").generateImage).toEqual( - expect.any(Function), - ); - }); }); diff --git a/src/plugins/contracts/tts.contract.test.ts b/src/plugins/contracts/tts.contract.test.ts index 4624d7b8510..84dd39b3655 100644 --- a/src/plugins/contracts/tts.contract.test.ts +++ b/src/plugins/contracts/tts.contract.test.ts @@ -1,11 +1,9 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildElevenLabsSpeechProvider } from "../../../extensions/elevenlabs/test-api.js"; -import { buildMicrosoftSpeechProvider } from "../../../extensions/microsoft/test-api.js"; -import { buildOpenAISpeechProvider } from "../../../extensions/openai/test-api.js"; import type { OpenClawConfig } from "../../config/config.js"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import type { SpeechProviderPlugin } from "../../plugins/types.js"; import { withEnv } from "../../test-utils/env.js"; import * as tts from "../../tts/tts.js"; @@ -124,6 +122,185 @@ function createOpenAiTelephonyCfg(model: "tts-1" | "gpt-4o-mini-tts"): OpenClawC }; } +function createAudioBuffer(length = 2): Buffer { + return Buffer.from(new Uint8Array(length).fill(1)); +} + +function resolveBaseUrl(rawValue: unknown, fallback: string): string { + return typeof rawValue === "string" && rawValue.trim() ? rawValue.replace(/\/+$/u, "") : fallback; +} + +function buildTestOpenAISpeechProvider(): SpeechProviderPlugin { + return { + id: "openai", + label: "OpenAI", + autoSelectOrder: 10, + resolveConfig: ({ rawConfig }) => { + const config = (rawConfig.openai ?? {}) as Record; + return { + ...config, + baseUrl: resolveBaseUrl( + config.baseUrl ?? process.env.OPENAI_TTS_BASE_URL, + "https://api.openai.com/v1", + ), + }; + }, + parseDirectiveToken: ({ key, value, providerConfig }) => { + if (key === "voice") { + const baseUrl = resolveBaseUrl( + (providerConfig as Record | undefined)?.baseUrl, + "https://api.openai.com/v1", + ); + const isDefaultEndpoint = baseUrl === "https://api.openai.com/v1"; + const allowedVoices = new Set([ + "alloy", + "ash", + "ballad", + "coral", + "echo", + "sage", + "shimmer", + "verse", + ]); + if (isDefaultEndpoint && !allowedVoices.has(value)) { + return { handled: true, warnings: [`invalid OpenAI voice "${value}"`] }; + } + return { handled: true, overrides: { voice: value } }; + } + if (key === "model") { + const baseUrl = resolveBaseUrl( + (providerConfig as Record | undefined)?.baseUrl, + "https://api.openai.com/v1", + ); + const isDefaultEndpoint = baseUrl === "https://api.openai.com/v1"; + const allowedModels = new Set(["tts-1", "tts-1-hd", "gpt-4o-mini-tts"]); + if (isDefaultEndpoint && !allowedModels.has(value)) { + return { handled: true, warnings: [`invalid OpenAI model "${value}"`] }; + } + return { handled: true, overrides: { model: value } }; + } + return { handled: false }; + }, + isConfigured: ({ providerConfig }) => + typeof (providerConfig as Record | undefined)?.apiKey === "string" || + typeof process.env.OPENAI_API_KEY === "string", + synthesize: async ({ text, providerConfig, providerOverrides }) => { + const config = providerConfig as Record | undefined; + await fetch(`${resolveBaseUrl(config?.baseUrl, "https://api.openai.com/v1")}/audio/speech`, { + method: "POST", + body: JSON.stringify({ + input: text, + model: providerOverrides?.model ?? config?.model ?? "gpt-4o-mini-tts", + voice: providerOverrides?.voice ?? config?.voice ?? "alloy", + }), + }); + return { + audioBuffer: createAudioBuffer(1), + outputFormat: "mp3", + fileExtension: ".mp3", + voiceCompatible: true, + }; + }, + synthesizeTelephony: async ({ text, providerConfig }) => { + const config = providerConfig as Record | undefined; + const configuredModel = typeof config?.model === "string" ? config.model : undefined; + const model = configuredModel ?? "tts-1"; + const configuredInstructions = + typeof config?.instructions === "string" ? config.instructions : undefined; + const instructions = + model === "gpt-4o-mini-tts" ? configuredInstructions || undefined : undefined; + await fetch(`${resolveBaseUrl(config?.baseUrl, "https://api.openai.com/v1")}/audio/speech`, { + method: "POST", + body: JSON.stringify({ + input: text, + model, + voice: config?.voice ?? "alloy", + instructions, + }), + }); + return { + audioBuffer: createAudioBuffer(2), + outputFormat: "mp3", + sampleRate: 24000, + }; + }, + listVoices: async () => [{ id: "alloy", label: "Alloy" }], + }; +} + +function buildTestMicrosoftSpeechProvider(): SpeechProviderPlugin { + return { + id: "microsoft", + label: "Microsoft", + aliases: ["edge"], + autoSelectOrder: 30, + resolveConfig: ({ rawConfig }) => { + const edgeConfig = (rawConfig.edge ?? rawConfig.microsoft ?? {}) as Record; + return { + ...edgeConfig, + outputFormat: edgeConfig.outputFormat ?? "audio-24khz-48kbitrate-mono-mp3", + }; + }, + isConfigured: () => true, + synthesize: async () => ({ + audioBuffer: createAudioBuffer(), + outputFormat: "mp3", + fileExtension: ".mp3", + voiceCompatible: true, + }), + listVoices: async () => [{ id: "edge", label: "Edge" }], + }; +} + +function buildTestElevenLabsSpeechProvider(): SpeechProviderPlugin { + return { + id: "elevenlabs", + label: "ElevenLabs", + autoSelectOrder: 20, + parseDirectiveToken: ({ key, value, currentOverrides }) => { + if (key === "voiceid") { + return { handled: true, overrides: { voiceId: value } }; + } + if (key === "stability") { + return { + handled: true, + overrides: { + voiceSettings: { + ...(currentOverrides as { voiceSettings?: Record } | undefined) + ?.voiceSettings, + stability: Number(value), + }, + }, + }; + } + if (key === "speed") { + return { + handled: true, + overrides: { + voiceSettings: { + ...(currentOverrides as { voiceSettings?: Record } | undefined) + ?.voiceSettings, + speed: Number(value), + }, + }, + }; + } + return { handled: false }; + }, + isConfigured: ({ providerConfig }) => + typeof (providerConfig as Record | undefined)?.apiKey === "string" || + typeof process.env.ELEVENLABS_API_KEY === "string" || + typeof process.env.XI_API_KEY === "string", + synthesize: async () => ({ + audioBuffer: createAudioBuffer(), + outputFormat: "mp3", + fileExtension: ".mp3", + voiceCompatible: true, + }), + listVoices: async () => [{ id: "eleven", label: "Eleven" }], + }; +} + describe("tts", () => { beforeEach(async () => { ({ completeSimple } = await import("@mariozechner/pi-ai")); @@ -136,9 +313,9 @@ describe("tts", () => { prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); const registry = createEmptyPluginRegistry(); registry.speechProviders = [ - { pluginId: "openai", provider: buildOpenAISpeechProvider(), source: "test" }, - { pluginId: "microsoft", provider: buildMicrosoftSpeechProvider(), source: "test" }, - { pluginId: "elevenlabs", provider: buildElevenLabsSpeechProvider(), source: "test" }, + { pluginId: "openai", provider: buildTestOpenAISpeechProvider(), source: "test" }, + { pluginId: "microsoft", provider: buildTestMicrosoftSpeechProvider(), source: "test" }, + { pluginId: "elevenlabs", provider: buildTestElevenLabsSpeechProvider(), source: "test" }, ]; setActivePluginRegistry(registry, "tts-test"); vi.clearAllMocks(); diff --git a/src/plugins/contracts/web-search-provider.contract.test.ts b/src/plugins/contracts/web-search-provider.contract.test.ts deleted file mode 100644 index ca51d97862e..00000000000 --- a/src/plugins/contracts/web-search-provider.contract.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { webSearchProviderContractRegistry } from "./registry.js"; -import { installWebSearchProviderContractSuite } from "./suites.js"; - -describe("web search provider contract registry load", () => { - it("loads bundled web search providers", () => { - expect(webSearchProviderContractRegistry.length).toBeGreaterThan(0); - }); -}); - -for (const entry of webSearchProviderContractRegistry) { - describe(`${entry.pluginId}:${entry.provider.id} web search contract`, () => { - installWebSearchProviderContractSuite({ - provider: entry.provider, - credentialValue: entry.credentialValue, - }); - }); -} diff --git a/src/plugins/install-min-host-version-guardrails.test.ts b/src/plugins/install-min-host-version-guardrails.test.ts deleted file mode 100644 index d8fada77619..00000000000 --- a/src/plugins/install-min-host-version-guardrails.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { isAtLeast, parseSemver } from "../infra/runtime-guard.js"; -import { parseMinHostVersionRequirement } from "./min-host-version.js"; - -const MIN_HOST_VERSION_BASELINE = "2026.3.22"; -const PLUGIN_MANIFEST_PATHS_REQUIRING_MIN_HOST_VERSION = [ - "extensions/bluebubbles/package.json", - "extensions/discord/package.json", - "extensions/feishu/package.json", - "extensions/googlechat/package.json", - "extensions/irc/package.json", - "extensions/line/package.json", - "extensions/matrix/package.json", - "extensions/mattermost/package.json", - "extensions/memory-lancedb/package.json", - "extensions/msteams/package.json", - "extensions/nextcloud-talk/package.json", - "extensions/nostr/package.json", - "extensions/synology-chat/package.json", - "extensions/tlon/package.json", - "extensions/twitch/package.json", - "extensions/voice-call/package.json", - "extensions/whatsapp/package.json", - "extensions/zalo/package.json", - "extensions/zalouser/package.json", -] as const; - -type PackageJsonLike = { - openclaw?: { - install?: { - minHostVersion?: string; - }; - }; -}; - -describe("install minHostVersion guardrails", () => { - it("requires published plugins that depend on new sdk subpaths to declare a host floor", () => { - const baseline = parseSemver(MIN_HOST_VERSION_BASELINE); - expect(baseline).not.toBeNull(); - if (!baseline) { - return; - } - - for (const relativePath of PLUGIN_MANIFEST_PATHS_REQUIRING_MIN_HOST_VERSION) { - const manifest = JSON.parse( - fs.readFileSync(path.resolve(relativePath), "utf-8"), - ) as PackageJsonLike; - const requirement = parseMinHostVersionRequirement( - manifest.openclaw?.install?.minHostVersion, - ); - - expect( - requirement, - `${relativePath} should declare openclaw.install.minHostVersion`, - ).not.toBeNull(); - if (!requirement) { - continue; - } - const minimum = parseSemver(requirement.minimumLabel); - expect(minimum, `${relativePath} should use a parseable semver floor`).not.toBeNull(); - if (!minimum) { - continue; - } - expect( - isAtLeast(minimum, baseline), - `${relativePath} should require at least OpenClaw ${MIN_HOST_VERSION_BASELINE}`, - ).toBe(true); - } - }); -}); diff --git a/src/plugins/wired-hooks-message.test.ts b/src/plugins/wired-hooks-message.test.ts index a41c6013856..923bf8bd8ac 100644 --- a/src/plugins/wired-hooks-message.test.ts +++ b/src/plugins/wired-hooks-message.test.ts @@ -8,6 +8,8 @@ import { createHookRunner } from "./hooks.js"; import { createMockPluginRegistry } from "./hooks.test-helpers.js"; describe("message_sending hook runner", () => { + const demoChannelCtx = { channelId: "demo-channel" }; + it("runMessageSending invokes registered hooks and returns modified content", async () => { const handler = vi.fn().mockReturnValue({ content: "modified content" }); const registry = createMockPluginRegistry([{ hookName: "message_sending", handler }]); @@ -15,12 +17,12 @@ describe("message_sending hook runner", () => { const result = await runner.runMessageSending( { to: "user-123", content: "original content" }, - { channelId: "telegram" }, + demoChannelCtx, ); expect(handler).toHaveBeenCalledWith( { to: "user-123", content: "original content" }, - { channelId: "telegram" }, + demoChannelCtx, ); expect(result?.content).toBe("modified content"); }); @@ -32,7 +34,7 @@ describe("message_sending hook runner", () => { const result = await runner.runMessageSending( { to: "user-123", content: "blocked" }, - { channelId: "telegram" }, + demoChannelCtx, ); expect(result?.cancel).toBe(true); @@ -40,6 +42,8 @@ describe("message_sending hook runner", () => { }); describe("message_sent hook runner", () => { + const demoChannelCtx = { channelId: "demo-channel" }; + it("runMessageSent invokes registered hooks with success=true", async () => { const handler = vi.fn(); const registry = createMockPluginRegistry([{ hookName: "message_sent", handler }]); @@ -47,12 +51,12 @@ describe("message_sent hook runner", () => { await runner.runMessageSent( { to: "user-123", content: "hello", success: true }, - { channelId: "telegram" }, + demoChannelCtx, ); expect(handler).toHaveBeenCalledWith( { to: "user-123", content: "hello", success: true }, - { channelId: "telegram" }, + demoChannelCtx, ); }); @@ -63,12 +67,12 @@ describe("message_sent hook runner", () => { await runner.runMessageSent( { to: "user-123", content: "hello", success: false, error: "timeout" }, - { channelId: "telegram" }, + demoChannelCtx, ); expect(handler).toHaveBeenCalledWith( { to: "user-123", content: "hello", success: false, error: "timeout" }, - { channelId: "telegram" }, + demoChannelCtx, ); }); }); diff --git a/src/test-utils/channel-plugins.test.ts b/src/test-utils/channel-plugins.test.ts index 453c7d23451..7d2b5e86097 100644 --- a/src/test-utils/channel-plugins.test.ts +++ b/src/test-utils/channel-plugins.test.ts @@ -4,11 +4,11 @@ import { createChannelTestPluginBase, createOutboundTestPlugin } from "./channel describe("createChannelTestPluginBase", () => { it("builds a plugin base with defaults", () => { const cfg = {} as never; - const base = createChannelTestPluginBase({ id: "telegram", label: "Telegram" }); - expect(base.id).toBe("telegram"); - expect(base.meta.label).toBe("Telegram"); - expect(base.meta.selectionLabel).toBe("Telegram"); - expect(base.meta.docsPath).toBe("/channels/telegram"); + const base = createChannelTestPluginBase({ id: "demo-channel", label: "Demo Channel" }); + expect(base.id).toBe("demo-channel"); + expect(base.meta.label).toBe("Demo Channel"); + expect(base.meta.selectionLabel).toBe("Demo Channel"); + expect(base.meta.docsPath).toBe("/channels/demo-channel"); expect(base.capabilities.chatTypes).toEqual(["direct"]); expect(base.config.listAccountIds(cfg)).toEqual(["default"]); expect(base.config.resolveAccount(cfg)).toEqual({}); @@ -17,16 +17,16 @@ describe("createChannelTestPluginBase", () => { it("honors config and metadata overrides", async () => { const cfg = {} as never; const base = createChannelTestPluginBase({ - id: "discord", - label: "Discord Bot", - docsPath: "/custom/discord", + id: "demo-chat", + label: "Demo Chat", + docsPath: "/custom/demo-chat", capabilities: { chatTypes: ["group"] }, config: { listAccountIds: () => ["acct-1"], isConfigured: async () => true, }, }); - expect(base.meta.docsPath).toBe("/custom/discord"); + expect(base.meta.docsPath).toBe("/custom/demo-chat"); expect(base.capabilities.chatTypes).toEqual(["group"]); expect(base.config.listAccountIds(cfg)).toEqual(["acct-1"]); const account = base.config.resolveAccount(cfg); @@ -38,11 +38,11 @@ describe("createOutboundTestPlugin", () => { it("keeps outbound test plugin account list behavior", () => { const cfg = {} as never; const plugin = createOutboundTestPlugin({ - id: "signal", + id: "demo-outbound", outbound: { deliveryMode: "direct", resolveTarget: () => ({ ok: true, to: "target" }), - sendText: async () => ({ channel: "signal", messageId: "m1" }), + sendText: async () => ({ channel: "demo-outbound", messageId: "m1" }), }, }); expect(plugin.config.listAccountIds(cfg)).toEqual([]); diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index 98bd4fffce2..185ddb52226 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -1,12 +1,24 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createMSTeamsTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { resolveGatewayMessageChannel } from "./message-channel.js"; const emptyRegistry = createTestRegistry([]); -const msteamsPlugin: ChannelPlugin = { - ...createMSTeamsTestPluginBase(), +const demoAliasPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "demo-alias-channel", + label: "Demo Alias Channel", + docsPath: "/channels/demo-alias-channel", + }), + meta: { + ...createChannelTestPluginBase({ + id: "demo-alias-channel", + label: "Demo Alias Channel", + docsPath: "/channels/demo-alias-channel", + }).meta, + aliases: ["workspace-chat"], + }, }; describe("message-channel", () => { @@ -27,8 +39,10 @@ describe("message-channel", () => { it("normalizes plugin aliases when registered", () => { setActivePluginRegistry( - createTestRegistry([{ pluginId: "msteams", plugin: msteamsPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "demo-alias-channel", plugin: demoAliasPlugin, source: "test" }, + ]), ); - expect(resolveGatewayMessageChannel("teams")).toBe("msteams"); + expect(resolveGatewayMessageChannel("workspace-chat")).toBe("demo-alias-channel"); }); }); diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts new file mode 100644 index 00000000000..fe17270ed4d --- /dev/null +++ b/test/extension-test-boundary.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); + +const allowedNonExtensionTests = new Set(); + +function walk(dir: string, entries: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") { + continue; + } + walk(fullPath, entries); + continue; + } + if (!entry.name.endsWith(".test.ts") && !entry.name.endsWith(".test.tsx")) { + continue; + } + entries.push(path.relative(repoRoot, fullPath).replaceAll(path.sep, "/")); + } + return entries; +} + +function findExtensionImports(source: string): string[] { + return [ + ...source.matchAll(/from\s+["']((?:\.\.\/)+extensions\/[^"']+)["']/g), + ...source.matchAll(/import\(\s*["']((?:\.\.\/)+extensions\/[^"']+)["']\s*\)/g), + ].map((match) => match[1]); +} + +describe("non-extension test boundaries", () => { + it("keeps extension-owned behavior suites under extensions/", () => { + const testFiles = [ + ...walk(path.join(repoRoot, "src")), + ...walk(path.join(repoRoot, "test")), + ...walk(path.join(repoRoot, "packages")), + ].filter( + (file) => + !file.startsWith("extensions/") && + !file.startsWith("test/helpers/") && + !file.startsWith("ui/"), + ); + + const offenders = testFiles + .map((file) => { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + const imports = findExtensionImports(source); + if (imports.length === 0) { + return null; + } + if (allowedNonExtensionTests.has(file)) { + return null; + } + return { + file, + imports, + }; + }) + .filter((value): value is { file: string; imports: string[] } => value !== null); + + expect(offenders).toEqual([]); + }); +}); diff --git a/test/helpers/channels/channel-catalog-contract.ts b/test/helpers/channels/channel-catalog-contract.ts new file mode 100644 index 00000000000..49c06773009 --- /dev/null +++ b/test/helpers/channels/channel-catalog-contract.ts @@ -0,0 +1,212 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + getChannelPluginCatalogEntry, + listChannelPluginCatalogEntries, +} from "../../../src/channels/plugins/catalog.js"; + +type CatalogEntryMeta = { + id: string; + label: string; + selectionLabel: string; + docsPath: string; + blurb: string; + detailLabel?: string; + aliases?: string[]; +}; + +export function describeChannelCatalogEntryContract(params: { + channelId: string; + npmSpec: string; + alias?: string; +}) { + describe(`${params.channelId} channel catalog contract`, () => { + it("keeps the shipped catalog entry aligned", () => { + const entry = getChannelPluginCatalogEntry(params.channelId); + expect(entry?.install.npmSpec).toBe(params.npmSpec); + if (params.alias) { + expect(entry?.meta.aliases).toContain(params.alias); + } + }); + + it("appears in the channel catalog listing", () => { + const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); + expect(ids).toContain(params.channelId); + }); + }); +} + +export function describeBundledMetadataOnlyChannelCatalogContract(params: { + pluginId: string; + packageName: string; + npmSpec: string; + meta: CatalogEntryMeta; + defaultChoice?: string; +}) { + describe(`${params.pluginId} bundled metadata-only channel catalog contract`, () => { + it("includes the bundled metadata-only channel entry when the runtime entrypoint is omitted", () => { + const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-")); + const bundledDir = path.join(packageRoot, "dist", "extensions", params.pluginId); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw" }), + "utf8", + ); + fs.writeFileSync( + path.join(bundledDir, "package.json"), + JSON.stringify({ + name: params.packageName, + openclaw: { + extensions: ["./index.js"], + channel: params.meta, + install: { + npmSpec: params.npmSpec, + defaultChoice: params.defaultChoice, + }, + }, + }), + "utf8", + ); + fs.writeFileSync( + path.join(bundledDir, "openclaw.plugin.json"), + JSON.stringify({ id: params.pluginId, channels: [params.meta.id], configSchema: {} }), + "utf8", + ); + + const entry = listChannelPluginCatalogEntries({ + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"), + }, + }).find((item) => item.id === params.meta.id); + + expect(entry?.install.npmSpec).toBe(params.npmSpec); + expect(entry?.pluginId).toBe(params.pluginId); + }); + }); +} + +export function describeOfficialFallbackChannelCatalogContract(params: { + channelId: string; + npmSpec: string; + meta: CatalogEntryMeta; + packageName: string; + pluginId: string; + externalNpmSpec: string; + externalLabel: string; +}) { + describe(`${params.channelId} official fallback channel catalog contract`, () => { + it("includes shipped official channel catalog entries when bundled metadata is omitted", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-official-catalog-")); + const catalogPath = path.join(dir, "channel-catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: params.packageName, + openclaw: { + channel: params.meta, + install: { + npmSpec: params.npmSpec, + defaultChoice: "npm", + }, + }, + }, + ], + }), + ); + + const entry = listChannelPluginCatalogEntries({ + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", + }, + officialCatalogPaths: [catalogPath], + }).find((item) => item.id === params.channelId); + + expect(entry?.install.npmSpec).toBe(params.npmSpec); + expect(entry?.pluginId).toBeUndefined(); + }); + + it("lets external catalogs override shipped fallback channel metadata", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-fallback-catalog-")); + const bundledDir = path.join(dir, "dist", "extensions", params.pluginId); + const officialCatalogPath = path.join(dir, "channel-catalog.json"); + const externalCatalogPath = path.join(dir, "catalog.json"); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.writeFileSync( + path.join(bundledDir, "package.json"), + JSON.stringify({ + name: params.packageName, + openclaw: { + channel: { + ...params.meta, + label: `${params.meta.label} Bundled`, + selectionLabel: `${params.meta.label} Bundled`, + blurb: "bundled fallback", + }, + install: { npmSpec: params.npmSpec }, + }, + }), + "utf8", + ); + fs.writeFileSync( + officialCatalogPath, + JSON.stringify({ + entries: [ + { + name: params.packageName, + openclaw: { + channel: { + ...params.meta, + label: `${params.meta.label} Official`, + selectionLabel: `${params.meta.label} Official`, + blurb: "official fallback", + }, + install: { npmSpec: params.npmSpec }, + }, + }, + ], + }), + "utf8", + ); + fs.writeFileSync( + externalCatalogPath, + JSON.stringify({ + entries: [ + { + name: params.externalNpmSpec, + openclaw: { + channel: { + ...params.meta, + label: params.externalLabel, + selectionLabel: params.externalLabel, + blurb: "external override", + }, + install: { npmSpec: params.externalNpmSpec }, + }, + }, + ], + }), + "utf8", + ); + + const entry = listChannelPluginCatalogEntries({ + catalogPaths: [externalCatalogPath], + officialCatalogPaths: [officialCatalogPath], + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(dir, "dist", "extensions"), + }, + }).find((item) => item.id === params.channelId); + + expect(entry?.install.npmSpec).toBe(params.externalNpmSpec); + expect(entry?.meta.label).toBe(params.externalLabel); + expect(entry?.pluginId).toBeUndefined(); + }); + }); +} diff --git a/test/helpers/channels/dm-policy-contract.ts b/test/helpers/channels/dm-policy-contract.ts new file mode 100644 index 00000000000..64bff7156c8 --- /dev/null +++ b/test/helpers/channels/dm-policy-contract.ts @@ -0,0 +1,65 @@ +import { expect, it } from "vitest"; +import { isAllowedBlueBubblesSender } from "../../../extensions/bluebubbles/api.js"; +import { isMattermostSenderAllowed } from "../../../extensions/mattermost/api.js"; +import { isSignalSenderAllowed, type SignalSender } from "../../../extensions/signal/api.js"; +import { + DM_GROUP_ACCESS_REASON, + resolveDmGroupAccessWithLists, +} from "../../../src/security/dm-policy-shared.js"; + +type ChannelSmokeCase = { + storeAllowFrom: string[]; + isSenderAllowed: (allowFrom: string[]) => boolean; +}; + +const signalSender: SignalSender = { + kind: "phone", + raw: "+15550001111", + e164: "+15550001111", +}; + +const dmPolicyCases = { + bluebubbles: { + storeAllowFrom: ["attacker-user"], + isSenderAllowed: (allowFrom: string[]) => + isAllowedBlueBubblesSender({ + allowFrom, + sender: "attacker-user", + chatId: 101, + }), + }, + signal: { + storeAllowFrom: [signalSender.e164], + isSenderAllowed: (allowFrom: string[]) => isSignalSenderAllowed(signalSender, allowFrom), + }, + mattermost: { + storeAllowFrom: ["user:attacker-user"], + isSenderAllowed: (allowFrom: string[]) => + isMattermostSenderAllowed({ + senderId: "attacker-user", + senderName: "Attacker", + allowFrom, + }), + }, +} satisfies Record; + +export function installDmPolicyContractSuite(channel: keyof typeof dmPolicyCases) { + const testCase = dmPolicyCases[channel]; + + for (const ingress of ["message", "reaction"] as const) { + it(`blocks group ${ingress} when sender is only in pairing store`, () => { + const access = resolveDmGroupAccessWithLists({ + isGroup: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner-user"], + groupAllowFrom: ["group-owner"], + storeAllowFrom: testCase.storeAllowFrom, + isSenderAllowed: testCase.isSenderAllowed, + }); + expect(access.decision).toBe("block"); + expect(access.reasonCode).toBe(DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED); + expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); + }); + } +} diff --git a/test/helpers/channels/group-policy-contract.ts b/test/helpers/channels/group-policy-contract.ts new file mode 100644 index 00000000000..799a7256038 --- /dev/null +++ b/test/helpers/channels/group-policy-contract.ts @@ -0,0 +1,95 @@ +import { expect, it } from "vitest"; +import { __testing as discordMonitorTesting } from "../../../extensions/discord/src/monitor/provider.js"; +import { __testing as imessageMonitorTesting } from "../../../extensions/imessage/src/monitor/monitor-provider.js"; +import { __testing as slackMonitorTesting } from "../../../extensions/slack/src/monitor/provider.js"; +import { resolveTelegramRuntimeGroupPolicy } from "../../../extensions/telegram/runtime-api.js"; +import { whatsappAccessControlTesting } from "../../../extensions/whatsapp/api.js"; +import { + evaluateZaloGroupAccess, + resolveZaloRuntimeGroupPolicy, +} from "../../../extensions/zalo/api.js"; +import { installChannelRuntimeGroupPolicyFallbackSuite } from "../../../src/channels/plugins/contracts/suites.js"; + +export function installSlackGroupPolicyContractSuite() { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: slackMonitorTesting.resolveSlackRuntimeGroupPolicy, + configuredLabel: "keeps open default when channels.slack is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); +} + +export function installTelegramGroupPolicyContractSuite() { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: resolveTelegramRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.telegram is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set", + missingDefaultLabel: "ignores explicit defaults when provider config is missing", + }); +} + +export function installWhatsAppGroupPolicyContractSuite() { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: whatsappAccessControlTesting.resolveWhatsAppRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.whatsapp is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); +} + +export function installIMessageGroupPolicyContractSuite() { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: imessageMonitorTesting.resolveIMessageRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.imessage is configured", + defaultGroupPolicyUnderTest: "disabled", + missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); +} + +export function installDiscordGroupPolicyContractSuite() { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: discordMonitorTesting.resolveDiscordRuntimeGroupPolicy, + configuredLabel: "keeps open default when channels.discord is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + + it("respects explicit provider policy", () => { + const resolved = discordMonitorTesting.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: false, + groupPolicy: "disabled", + }); + expect(resolved.groupPolicy).toBe("disabled"); + expect(resolved.providerMissingFallbackApplied).toBe(false); + }); +} + +export function installZaloGroupPolicyContractSuite() { + installChannelRuntimeGroupPolicyFallbackSuite({ + resolve: resolveZaloRuntimeGroupPolicy, + configuredLabel: "keeps open fallback when channels.zalo is configured", + defaultGroupPolicyUnderTest: "open", + missingConfigLabel: "fails closed when channels.zalo is missing and no defaults are set", + missingDefaultLabel: "ignores explicit global defaults when provider config is missing", + }); + + it("keeps provider-owned group access evaluation", () => { + const decision = evaluateZaloGroupAccess({ + providerConfigPresent: true, + configuredGroupPolicy: "allowlist", + defaultGroupPolicy: "open", + groupAllowFrom: ["zl:12345"], + senderId: "12345", + }); + expect(decision).toMatchObject({ + allowed: true, + groupPolicy: "allowlist", + reason: "allowed", + }); + }); +} diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/test/helpers/channels/inbound-contract.ts similarity index 76% rename from src/channels/plugins/contracts/inbound.contract.test.ts rename to test/helpers/channels/inbound-contract.ts index 986c4cdc67a..f01980d2e3b 100644 --- a/src/channels/plugins/contracts/inbound.contract.test.ts +++ b/test/helpers/channels/inbound-contract.ts @@ -1,17 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildFinalizedDiscordDirectInboundContext } from "../../../../extensions/discord/test-api.js"; +import { expect, it, vi } from "vitest"; +import { buildFinalizedDiscordDirectInboundContext } from "../../../extensions/discord/test-api.js"; import { createInboundSlackTestContext, prepareSlackMessage, type ResolvedSlackAccount, type SlackMessageEvent, -} from "../../../../extensions/slack/test-api.js"; -import { buildTelegramMessageContextForTest } from "../../../../extensions/telegram/test-api.js"; -import { withTempHome } from "../../../../test/helpers/temp-home.js"; -import type { MsgContext } from "../../../auto-reply/templating.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { inboundCtxCapture } from "./inbound-testkit.js"; -import { expectChannelInboundContextContract } from "./suites.js"; +} from "../../../extensions/slack/test-api.js"; +import { buildTelegramMessageContextForTest } from "../../../extensions/telegram/test-api.js"; +import type { MsgContext } from "../../../src/auto-reply/templating.js"; +import { inboundCtxCapture } from "../../../src/channels/plugins/contracts/inbound-testkit.js"; +import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/suites.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { withTempHome } from "../temp-home.js"; const dispatchInboundMessageMock = vi.hoisted(() => vi.fn( @@ -54,20 +54,19 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { }; }); -vi.mock("../../../../extensions/signal/api.js", () => ({ +vi.mock("../../../extensions/signal/api.js", () => ({ sendMessageSignal: vi.fn(), sendTypingSignal: vi.fn(async () => true), sendReadReceiptSignal: vi.fn(async () => true), })); -vi.mock("../../../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: vi.fn().mockResolvedValue([]), upsertChannelPairingRequest: vi.fn(), })); -vi.mock("../../../../extensions/whatsapp/test-api.js", async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock("../../../extensions/whatsapp/test-api.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, trackBackgroundTask: (tasks: Set>, task: Promise) => { @@ -81,7 +80,7 @@ vi.mock("../../../../extensions/whatsapp/test-api.js", async (importOriginal) => }; }); -const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js"); +const { finalizeInboundContext } = await import("../../../src/auto-reply/reply/inbound-context.js"); function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount { return { @@ -108,19 +107,16 @@ function createSlackMessage(overrides: Partial): SlackMessage } as SlackMessageEvent; } -describe("channel inbound contract", () => { - beforeEach(() => { - inboundCtxCapture.ctx = undefined; - dispatchInboundMessageMock.mockClear(); - }); - - it("keeps Discord inbound context finalized", () => { +export function installDiscordInboundContractSuite() { + it("keeps inbound context finalized", () => { const ctx = buildFinalizedDiscordDirectInboundContext(); expectChannelInboundContextContract(ctx); }); +} - it("keeps Signal inbound context finalized", async () => { +export function installSignalInboundContractSuite() { + it("keeps inbound context finalized", () => { const ctx = finalizeInboundContext({ Body: "Alice: hi", BodyForAgent: "hi", @@ -146,16 +142,17 @@ describe("channel inbound contract", () => { expectChannelInboundContextContract(ctx); }); +} - it("keeps Slack inbound context finalized", async () => { +export function installSlackInboundContractSuite() { + it("keeps inbound context finalized", async () => { await withTempHome(async () => { const ctx = createInboundSlackTestContext({ cfg: { channels: { slack: { enabled: true } }, } as OpenClawConfig, }); - // oxlint-disable-next-line typescript/no-explicit-any - ctx.resolveUserName = async () => ({ name: "Alice" }) as any; + ctx.resolveUserName = async () => ({ name: "Alice" }) as never; const prepared = await prepareSlackMessage({ ctx, @@ -168,8 +165,10 @@ describe("channel inbound contract", () => { expectChannelInboundContextContract(prepared!.ctxPayload); }); }); +} - it("keeps Telegram inbound context finalized", async () => { +export function installTelegramInboundContractSuite() { + it("keeps inbound context finalized", async () => { const context = await buildTelegramMessageContextForTest({ cfg: { agents: { @@ -200,10 +199,15 @@ describe("channel inbound contract", () => { const payload = context?.ctxPayload; expect(payload).toBeTruthy(); - expectChannelInboundContextContract(payload!); + if (!payload) { + throw new Error("expected telegram inbound payload"); + } + expectChannelInboundContextContract(payload); }); +} - it("keeps WhatsApp inbound context finalized", async () => { +export function installWhatsAppInboundContractSuite() { + it("keeps inbound context finalized", () => { const ctx = finalizeInboundContext({ Body: "Alice: hi", BodyForAgent: "hi", @@ -230,4 +234,4 @@ describe("channel inbound contract", () => { expectChannelInboundContextContract(ctx); }); -}); +} diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/test/helpers/channels/outbound-payload-contract.ts similarity index 70% rename from src/channels/plugins/contracts/outbound-payload.contract.test.ts rename to test/helpers/channels/outbound-payload-contract.ts index f1550ecf238..2b3192282c9 100644 --- a/src/channels/plugins/contracts/outbound-payload.contract.test.ts +++ b/test/helpers/channels/outbound-payload-contract.ts @@ -1,38 +1,34 @@ -import { describe, vi } from "vitest"; -import { discordOutbound } from "../../../../extensions/discord/test-api.js"; -import { whatsappOutbound } from "../../../../extensions/whatsapp/test-api.js"; -import { sendMessageZalo } from "../../../../extensions/zalo/test-api.js"; +import { vi } from "vitest"; +import { discordOutbound } from "../../../extensions/discord/test-api.js"; +import { whatsappOutbound } from "../../../extensions/whatsapp/test-api.js"; +import { sendMessageZalo } from "../../../extensions/zalo/test-api.js"; import { sendMessageZalouser, parseZalouserOutboundTarget, -} from "../../../../extensions/zalouser/test-api.js"; -import { - chunkTextForOutbound as chunkZaloTextForOutbound, - sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia, -} from "../../../../src/plugin-sdk/zalo.js"; -import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../../src/plugin-sdk/zalouser.js"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { createDirectTextMediaOutbound } from "../outbound/direct-text-media.js"; +} from "../../../extensions/zalouser/test-api.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; import { createSlackOutboundPayloadHarness, installChannelOutboundPayloadContractSuite, primeChannelOutboundSendMock, -} from "./suites.js"; +} from "../../../src/channels/plugins/contracts/suites.js"; +import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js"; +import { + chunkTextForOutbound as chunkZaloTextForOutbound, + sendPayloadWithChunkedTextAndMedia as sendZaloPayloadWithChunkedTextAndMedia, +} from "../../../src/plugin-sdk/zalo.js"; +import { sendPayloadWithChunkedTextAndMedia as sendZalouserPayloadWithChunkedTextAndMedia } from "../../../src/plugin-sdk/zalouser.js"; -vi.mock("../../../../extensions/zalo/test-api.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../extensions/zalo/test-api.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }), }; }); -// This suite only validates payload adaptation. Keep zalouser's runtime-only -// ZCA import graph mocked so local contract runs don't depend on native socket -// deps being resolved through the extension runtime seam. -vi.mock("../../../../extensions/zalouser/test-api.js", async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock("../../../extensions/zalouser/test-api.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listZalouserAccountIds: vi.fn(() => ["default"]), @@ -84,9 +80,6 @@ function buildChannelSendResult(channel: string, result: Record }; } -const mockedSendZalo = vi.mocked(sendMessageZalo); -const mockedSendZalouser = vi.mocked(sendMessageZalouser); - function createDiscordHarness(params: PayloadHarnessParams) { const sendDiscord = vi.fn(); primeChannelOutboundSendMock( @@ -153,6 +146,7 @@ function createDirectTextMediaHarness(params: PayloadHarnessParams) { } function createZaloHarness(params: PayloadHarnessParams) { + const mockedSendZalo = vi.mocked(sendMessageZalo); primeChannelOutboundSendMock(mockedSendZalo, { ok: true, messageId: "zl-1" }, params.sendResults); const ctx = { cfg: {}, @@ -191,6 +185,7 @@ function createZaloHarness(params: PayloadHarnessParams) { } function createZalouserHarness(params: PayloadHarnessParams) { + const mockedSendZalouser = vi.mocked(sendMessageZalouser); primeChannelOutboundSendMock( mockedSendZalouser, { ok: true, messageId: "zlu-1" }, @@ -240,52 +235,50 @@ function createZalouserHarness(params: PayloadHarnessParams) { }; } -describe("channel outbound payload contract", () => { - describe("slack", () => { - installChannelOutboundPayloadContractSuite({ - channel: "slack", - chunking: { mode: "passthrough", longTextLength: 5000 }, - createHarness: createSlackOutboundPayloadHarness, - }); +export function installSlackOutboundPayloadContractSuite() { + installChannelOutboundPayloadContractSuite({ + channel: "slack", + chunking: { mode: "passthrough", longTextLength: 5000 }, + createHarness: createSlackOutboundPayloadHarness, }); +} - describe("discord", () => { - installChannelOutboundPayloadContractSuite({ - channel: "discord", - chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness: createDiscordHarness, - }); +export function installDiscordOutboundPayloadContractSuite() { + installChannelOutboundPayloadContractSuite({ + channel: "discord", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness: createDiscordHarness, }); +} - describe("whatsapp", () => { - installChannelOutboundPayloadContractSuite({ - channel: "whatsapp", - chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, - createHarness: createWhatsAppHarness, - }); +export function installWhatsAppOutboundPayloadContractSuite() { + installChannelOutboundPayloadContractSuite({ + channel: "whatsapp", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: createWhatsAppHarness, }); +} - describe("zalo", () => { - installChannelOutboundPayloadContractSuite({ - channel: "zalo", - chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, - createHarness: createZaloHarness, - }); +export function installZaloOutboundPayloadContractSuite() { + installChannelOutboundPayloadContractSuite({ + channel: "zalo", + chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + createHarness: createZaloHarness, }); +} - describe("zalouser", () => { - installChannelOutboundPayloadContractSuite({ - channel: "zalouser", - chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness: createZalouserHarness, - }); +export function installZalouserOutboundPayloadContractSuite() { + installChannelOutboundPayloadContractSuite({ + channel: "zalouser", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness: createZalouserHarness, }); +} - describe("direct-text-media", () => { - installChannelOutboundPayloadContractSuite({ - channel: "imessage", - chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, - createHarness: createDirectTextMediaHarness, - }); +export function installDirectTextMediaOutboundPayloadContractSuite() { + installChannelOutboundPayloadContractSuite({ + channel: "imessage", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: createDirectTextMediaHarness, }); -}); +} diff --git a/test/helpers/channels/plugins-core-extension-contract.ts b/test/helpers/channels/plugins-core-extension-contract.ts new file mode 100644 index 00000000000..b6189c6963f --- /dev/null +++ b/test/helpers/channels/plugins-core-extension-contract.ts @@ -0,0 +1,388 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, + type DiscordProbe, + type DiscordTokenResolution, +} from "../../../extensions/discord/api.js"; +import type { IMessageProbe } from "../../../extensions/imessage/api.js"; +import type { SignalProbe } from "../../../extensions/signal/api.js"; +import { + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, + type SlackProbe, +} from "../../../extensions/slack/api.js"; +import { + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, + type TelegramProbe, + type TelegramTokenResolution, +} from "../../../extensions/telegram/api.js"; +import { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "../../../extensions/whatsapp/api.js"; +import type { + BaseProbeResult, + BaseTokenResolution, + ChannelDirectoryEntry, +} from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { LineProbeResult } from "../../../src/plugin-sdk/line.js"; +import { withEnvAsync } from "../../../src/test-utils/env.js"; + +type DirectoryListFn = (params: { + cfg: OpenClawConfig; + accountId?: string; + query?: string | null; + limit?: number | null; +}) => Promise; + +async function listDirectoryEntriesWithDefaults(listFn: DirectoryListFn, cfg: OpenClawConfig) { + return await listFn({ + cfg, + accountId: "default", + query: null, + limit: null, + }); +} + +async function expectDirectoryIds( + listFn: DirectoryListFn, + cfg: OpenClawConfig, + expected: string[], + options?: { sorted?: boolean }, +) { + const entries = await listDirectoryEntriesWithDefaults(listFn, cfg); + const ids = entries.map((entry) => entry.id); + expect(options?.sorted ? ids.toSorted() : ids).toEqual(expected); +} + +export function describeDiscordPluginsCoreExtensionContract() { + describe("discord plugins-core extension contract", () => { + it("DiscordProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("Discord token resolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("lists peers/groups from config (numeric ids only)", async () => { + const cfg = { + channels: { + discord: { + token: "discord-test", + dm: { allowFrom: ["<@111>", "<@!333>", "nope"] }, + dms: { "222": {} }, + guilds: { + "123": { + users: ["<@12345>", " discord:444 ", "not-an-id"], + channels: { + "555": {}, + "<#777>": {}, + "channel:666": {}, + general: {}, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + await expectDirectoryIds( + listDiscordDirectoryPeersFromConfig, + cfg, + ["user:111", "user:12345", "user:222", "user:333", "user:444"], + { sorted: true }, + ); + await expectDirectoryIds( + listDiscordDirectoryGroupsFromConfig, + cfg, + ["channel:555", "channel:666", "channel:777"], + { sorted: true }, + ); + }); + + it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const envSecret = { + source: "env", + provider: "default", + id: "MISSING_TEST_SECRET", + } as const; + const cfg = { + channels: { + discord: { + token: envSecret, + dm: { allowFrom: ["<@111>"] }, + guilds: { + "123": { + channels: { + "555": {}, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]); + await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]); + }); + + it("applies query and limit filtering for config-backed directories", async () => { + const cfg = { + channels: { + discord: { + token: "discord-test", + guilds: { + "123": { + channels: { + "555": {}, + "666": {}, + "777": {}, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const groups = await listDiscordDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: "666", + limit: 5, + }); + expect(groups.map((entry) => entry.id)).toEqual(["channel:666"]); + }); + }); +} + +export function describeSlackPluginsCoreExtensionContract() { + describe("slack plugins-core extension contract", () => { + it("SlackProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("lists peers/groups from config", async () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + dm: { allowFrom: ["U123", "user:U999"] }, + dms: { U234: {} }, + channels: { C111: { users: ["U777"] } }, + }, + }, + } as unknown as OpenClawConfig; + + await expectDirectoryIds( + listSlackDirectoryPeersFromConfig, + cfg, + ["user:u123", "user:u234", "user:u777", "user:u999"], + { sorted: true }, + ); + await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]); + }); + + it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const envSecret = { + source: "env", + provider: "default", + id: "MISSING_TEST_SECRET", + } as const; + const cfg = { + channels: { + slack: { + botToken: envSecret, + appToken: envSecret, + dm: { allowFrom: ["U123"] }, + channels: { C111: {} }, + }, + }, + } as unknown as OpenClawConfig; + + await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]); + await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]); + }); + + it("applies query and limit filtering for config-backed directories", async () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + dm: { allowFrom: ["U100", "U200"] }, + dms: { U300: {} }, + }, + }, + } as unknown as OpenClawConfig; + + const peers = await listSlackDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: "user:u", + limit: 2, + }); + expect(peers).toHaveLength(2); + expect(peers.every((entry) => entry.id.startsWith("user:u"))).toBe(true); + }); + }); +} + +export function describeTelegramPluginsCoreExtensionContract() { + describe("telegram plugins-core extension contract", () => { + it("TelegramProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("Telegram token resolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("lists peers/groups from config", async () => { + const cfg = { + channels: { + telegram: { + botToken: "telegram-test", + allowFrom: ["123", "alice", "tg:@bob"], + dms: { "456": {} }, + groups: { "-1001": {}, "*": {} }, + }, + }, + } as unknown as OpenClawConfig; + + await expectDirectoryIds( + listTelegramDirectoryPeersFromConfig, + cfg, + ["123", "456", "@alice", "@bob"], + { sorted: true }, + ); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); + }); + + it("keeps fallback semantics when accountId is omitted", async () => { + await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => { + const cfg = { + channels: { + telegram: { + allowFrom: ["alice"], + groups: { "-1001": {} }, + accounts: { + work: { + botToken: "tok-work", + allowFrom: ["bob"], + groups: { "-2002": {} }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); + }); + }); + + it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const envSecret = { + source: "env", + provider: "default", + id: "MISSING_TEST_SECRET", + } as const; + const cfg = { + channels: { + telegram: { + botToken: envSecret, + allowFrom: ["alice"], + groups: { "-1001": {} }, + }, + }, + } as unknown as OpenClawConfig; + + await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); + }); + + it("applies query and limit filtering for config-backed directories", async () => { + const cfg = { + channels: { + telegram: { + botToken: "telegram-test", + groups: { "-1001": {}, "-1002": {}, "-2001": {} }, + }, + }, + } as unknown as OpenClawConfig; + + const groups = await listTelegramDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: "-100", + limit: 1, + }); + expect(groups.map((entry) => entry.id)).toEqual(["-1001"]); + }); + }); +} + +export function describeWhatsAppPluginsCoreExtensionContract() { + describe("whatsapp plugins-core extension contract", () => { + it("lists peers/groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + allowFrom: ["+15550000000", "*", "123@g.us"], + groups: { "999@g.us": { requireMention: true }, "*": {} }, + }, + }, + } as unknown as OpenClawConfig; + + await expectDirectoryIds(listWhatsAppDirectoryPeersFromConfig, cfg, ["+15550000000"]); + await expectDirectoryIds(listWhatsAppDirectoryGroupsFromConfig, cfg, ["999@g.us"]); + }); + + it("applies query and limit filtering for config-backed directories", async () => { + const cfg = { + channels: { + whatsapp: { + groups: { "111@g.us": {}, "222@g.us": {}, "333@s.whatsapp.net": {} }, + }, + }, + } as unknown as OpenClawConfig; + + const groups = await listWhatsAppDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: "@g.us", + limit: 1, + }); + expect(groups.map((entry) => entry.id)).toEqual(["111@g.us"]); + }); + }); +} + +export function describeSignalPluginsCoreExtensionContract() { + describe("signal plugins-core extension contract", () => { + it("SignalProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + }); +} + +export function describeIMessagePluginsCoreExtensionContract() { + describe("imessage plugins-core extension contract", () => { + it("IMessageProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + }); +} + +export function describeLinePluginsCoreExtensionContract() { + describe("line plugins-core extension contract", () => { + it("LineProbeResult satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + }); +} diff --git a/test/helpers/channels/registry-backed-contract.ts b/test/helpers/channels/registry-backed-contract.ts new file mode 100644 index 00000000000..bae8e254e61 --- /dev/null +++ b/test/helpers/channels/registry-backed-contract.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe } from "vitest"; +import { __testing as discordThreadBindingTesting } from "../../../extensions/discord/runtime-api.js"; +import { feishuThreadBindingTesting } from "../../../extensions/feishu/api.js"; +import { resetMatrixThreadBindingsForTests } from "../../../extensions/matrix/api.js"; +import { __testing as telegramThreadBindingTesting } from "../../../extensions/telegram/src/thread-bindings.js"; +import { + actionContractRegistry, + directoryContractRegistry, + pluginContractRegistry, + sessionBindingContractRegistry, + setupContractRegistry, + statusContractRegistry, + surfaceContractRegistry, + threadingContractRegistry, +} from "../../../src/channels/plugins/contracts/registry.js"; +import { + installChannelActionsContractSuite, + installChannelDirectoryContractSuite, + installChannelPluginContractSuite, + installChannelSetupContractSuite, + installChannelStatusContractSuite, + installChannelSurfaceContractSuite, + installChannelThreadingContractSuite, + installSessionBindingContractSuite, +} from "../../../src/channels/plugins/contracts/suites.js"; +import { __testing as sessionBindingTesting } from "../../../src/infra/outbound/session-binding-service.js"; + +function hasEntries( + entries: readonly T[], + id: string, +): entries is readonly T[] { + return entries.some((entry) => entry.id === id); +} + +export function describeChannelRegistryBackedContracts(id: string) { + if (hasEntries(pluginContractRegistry, id)) { + const entry = pluginContractRegistry.find((item) => item.id === id)!; + describe(`${entry.id} plugin contract`, () => { + installChannelPluginContractSuite({ + plugin: entry.plugin, + }); + }); + } + + if (hasEntries(actionContractRegistry, id)) { + const entry = actionContractRegistry.find((item) => item.id === id)!; + describe(`${entry.id} actions contract`, () => { + installChannelActionsContractSuite({ + plugin: entry.plugin, + cases: entry.cases as never, + unsupportedAction: entry.unsupportedAction as never, + }); + }); + } + + if (hasEntries(setupContractRegistry, id)) { + const entry = setupContractRegistry.find((item) => item.id === id)!; + describe(`${entry.id} setup contract`, () => { + installChannelSetupContractSuite({ + plugin: entry.plugin, + cases: entry.cases as never, + }); + }); + } + + if (hasEntries(statusContractRegistry, id)) { + const entry = statusContractRegistry.find((item) => item.id === id)!; + describe(`${entry.id} status contract`, () => { + installChannelStatusContractSuite({ + plugin: entry.plugin, + cases: entry.cases as never, + }); + }); + } + + for (const entry of surfaceContractRegistry.filter((item) => item.id === id)) { + for (const surface of entry.surfaces) { + describe(`${entry.id} ${surface} surface contract`, () => { + installChannelSurfaceContractSuite({ + plugin: entry.plugin, + surface, + }); + }); + } + } + + if (hasEntries(threadingContractRegistry, id)) { + const entry = threadingContractRegistry.find((item) => item.id === id)!; + describe(`${entry.id} threading contract`, () => { + installChannelThreadingContractSuite({ + plugin: entry.plugin, + }); + }); + } + + if (hasEntries(directoryContractRegistry, id)) { + const entry = directoryContractRegistry.find((item) => item.id === id)!; + describe(`${entry.id} directory contract`, () => { + installChannelDirectoryContractSuite({ + plugin: entry.plugin, + coverage: entry.coverage, + cfg: entry.cfg, + accountId: entry.accountId, + }); + }); + } +} + +export function describeSessionBindingRegistryBackedContract(id: string) { + const entry = sessionBindingContractRegistry.find((item) => item.id === id); + if (!entry) { + throw new Error(`missing session binding contract entry for ${id}`); + } + + describe(`${entry.id} session binding contract`, () => { + beforeEach(async () => { + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + discordThreadBindingTesting.resetThreadBindingsForTests(); + feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); + resetMatrixThreadBindingsForTests(); + await telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); + }); + + installSessionBindingContractSuite({ + expectedCapabilities: entry.expectedCapabilities, + getCapabilities: entry.getCapabilities, + bindAndResolve: entry.bindAndResolve, + unbindAndVerify: entry.unbindAndVerify, + cleanup: entry.cleanup, + }); + }); +} diff --git a/test/helpers/channels/session-binding-contract.ts b/test/helpers/channels/session-binding-contract.ts new file mode 100644 index 00000000000..da093a58c9f --- /dev/null +++ b/test/helpers/channels/session-binding-contract.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { sessionBindingContractChannelIds } from "../../../src/channels/plugins/contracts/manifest.js"; + +export function describeSessionBindingContractCoverage(channelIds: readonly string[]) { + describe("session binding contract coverage", () => { + for (const channelId of channelIds) { + it(`includes ${channelId} in the shared session binding contract registry`, () => { + expect(sessionBindingContractChannelIds).toContain(channelId); + }); + } + }); +} diff --git a/test/helpers/extensions/bundled-web-search-fast-path-contract.ts b/test/helpers/extensions/bundled-web-search-fast-path-contract.ts new file mode 100644 index 00000000000..0e5ec69549e --- /dev/null +++ b/test/helpers/extensions/bundled-web-search-fast-path-contract.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { loadBundledCapabilityRuntimeRegistry } from "../../../src/plugins/bundled-capability-runtime.js"; +import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "../../../src/plugins/bundled-web-search-ids.js"; +import { resolveBundledWebSearchPluginId } from "../../../src/plugins/bundled-web-search-provider-ids.js"; +import { listBundledWebSearchProviders } from "../../../src/plugins/bundled-web-search.js"; + +type ComparableProvider = { + pluginId: string; + id: string; + label: string; + hint: string; + envVars: string[]; + placeholder: string; + signupUrl: string; + docsUrl?: string; + autoDetectOrder?: number; + requiresCredential?: boolean; + credentialPath: string; + inactiveSecretPaths?: string[]; + hasConfiguredCredentialAccessors: boolean; + hasApplySelectionConfig: boolean; + hasResolveRuntimeMetadata: boolean; +}; + +function toComparableEntry(params: { + pluginId: string; + provider: { + id: string; + label: string; + hint: string; + envVars: string[]; + placeholder: string; + signupUrl: string; + docsUrl?: string; + autoDetectOrder?: number; + requiresCredential?: boolean; + credentialPath: string; + inactiveSecretPaths?: string[]; + getConfiguredCredentialValue?: unknown; + setConfiguredCredentialValue?: unknown; + applySelectionConfig?: unknown; + resolveRuntimeMetadata?: unknown; + }; +}): ComparableProvider { + return { + pluginId: params.pluginId, + id: params.provider.id, + label: params.provider.label, + hint: params.provider.hint, + envVars: params.provider.envVars, + placeholder: params.provider.placeholder, + signupUrl: params.provider.signupUrl, + docsUrl: params.provider.docsUrl, + autoDetectOrder: params.provider.autoDetectOrder, + requiresCredential: params.provider.requiresCredential, + credentialPath: params.provider.credentialPath, + inactiveSecretPaths: params.provider.inactiveSecretPaths, + hasConfiguredCredentialAccessors: + typeof params.provider.getConfiguredCredentialValue === "function" && + typeof params.provider.setConfiguredCredentialValue === "function", + hasApplySelectionConfig: typeof params.provider.applySelectionConfig === "function", + hasResolveRuntimeMetadata: typeof params.provider.resolveRuntimeMetadata === "function", + }; +} + +function sortComparableEntries(entries: ComparableProvider[]): ComparableProvider[] { + return [...entries].toSorted((left, right) => { + const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + return ( + leftOrder - rightOrder || + left.id.localeCompare(right.id) || + left.pluginId.localeCompare(right.pluginId) + ); + }); +} + +export function describeBundledWebSearchFastPathContract(pluginId: string) { + describe(`${pluginId} bundled web search fast-path contract`, () => { + it("keeps provider-to-plugin ids aligned with bundled contracts", () => { + const providers = listBundledWebSearchProviders().filter( + (provider) => provider.pluginId === pluginId, + ); + expect(providers.length).toBeGreaterThan(0); + for (const provider of providers) { + expect(resolveBundledWebSearchPluginId(provider.id)).toBe(pluginId); + } + }); + + it("keeps fast-path provider metadata aligned with the bundled runtime registry", async () => { + const fastPathProviders = listBundledWebSearchProviders().filter( + (provider) => provider.pluginId === pluginId, + ); + const bundledProviderEntries = loadBundledCapabilityRuntimeRegistry({ + pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS, + pluginSdkResolution: "dist", + }) + .webSearchProviders.filter((entry) => entry.pluginId === pluginId) + .map((entry) => ({ + pluginId: entry.pluginId, + ...entry.provider, + })); + + expect( + sortComparableEntries( + fastPathProviders.map((provider) => + toComparableEntry({ + pluginId: provider.pluginId, + provider, + }), + ), + ), + ).toEqual( + sortComparableEntries( + bundledProviderEntries.map(({ pluginId: entryPluginId, ...provider }) => + toComparableEntry({ + pluginId: entryPluginId, + provider, + }), + ), + ), + ); + + for (const fastPathProvider of fastPathProviders) { + const bundledEntry = bundledProviderEntries.find( + (entry) => entry.id === fastPathProvider.id, + ); + expect(bundledEntry).toBeDefined(); + const contractProvider = bundledEntry!; + + const fastSearchConfig: Record = {}; + const contractSearchConfig: Record = {}; + fastPathProvider.setCredentialValue(fastSearchConfig, "test-key"); + contractProvider.setCredentialValue(contractSearchConfig, "test-key"); + expect(fastSearchConfig).toEqual(contractSearchConfig); + expect(fastPathProvider.getCredentialValue(fastSearchConfig)).toEqual( + contractProvider.getCredentialValue(contractSearchConfig), + ); + + const fastConfig = {} as OpenClawConfig; + const contractConfig = {} as OpenClawConfig; + fastPathProvider.setConfiguredCredentialValue?.(fastConfig, "test-key"); + contractProvider.setConfiguredCredentialValue?.(contractConfig, "test-key"); + expect(fastConfig).toEqual(contractConfig); + expect(fastPathProvider.getConfiguredCredentialValue?.(fastConfig)).toEqual( + contractProvider.getConfiguredCredentialValue?.(contractConfig), + ); + + if (fastPathProvider.applySelectionConfig || contractProvider.applySelectionConfig) { + expect(fastPathProvider.applySelectionConfig?.({} as OpenClawConfig)).toEqual( + contractProvider.applySelectionConfig?.({} as OpenClawConfig), + ); + } + + if (fastPathProvider.resolveRuntimeMetadata || contractProvider.resolveRuntimeMetadata) { + const metadataCases = [ + { + searchConfig: fastSearchConfig, + resolvedCredential: { + value: "pplx-test", + source: "secretRef" as const, + fallbackEnvVar: undefined, + }, + }, + { + searchConfig: fastSearchConfig, + resolvedCredential: { + value: undefined, + source: "env" as const, + fallbackEnvVar: "OPENROUTER_API_KEY", + }, + }, + { + searchConfig: { + ...fastSearchConfig, + perplexity: { + ...(fastSearchConfig.perplexity as Record | undefined), + model: "custom-model", + }, + }, + resolvedCredential: { + value: "pplx-test", + source: "secretRef" as const, + fallbackEnvVar: undefined, + }, + }, + ]; + + for (const testCase of metadataCases) { + expect( + await fastPathProvider.resolveRuntimeMetadata?.({ + config: fastConfig, + searchConfig: testCase.searchConfig, + runtimeMetadata: { + diagnostics: [], + providerSource: "configured", + }, + resolvedCredential: testCase.resolvedCredential, + }), + ).toEqual( + await contractProvider.resolveRuntimeMetadata?.({ + config: contractConfig, + searchConfig: testCase.searchConfig, + runtimeMetadata: { + diagnostics: [], + providerSource: "configured", + }, + resolvedCredential: testCase.resolvedCredential, + }), + ); + } + } + } + }); + }); +} diff --git a/test/helpers/extensions/package-manifest-contract.ts b/test/helpers/extensions/package-manifest-contract.ts new file mode 100644 index 00000000000..e21cdf1eb65 --- /dev/null +++ b/test/helpers/extensions/package-manifest-contract.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { isAtLeast, parseSemver } from "../../../src/infra/runtime-guard.js"; +import { parseMinHostVersionRequirement } from "../../../src/plugins/min-host-version.js"; + +type PackageManifest = { + dependencies?: Record; + openclaw?: { + install?: { + minHostVersion?: string; + }; + }; +}; + +type PackageManifestContractParams = { + pluginId: string; + runtimeDeps?: string[]; + minHostVersionBaseline?: string; +}; + +function readJson(relativePath: string): T { + const absolutePath = path.resolve(process.cwd(), relativePath); + return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as T; +} + +export function describePackageManifestContract(params: PackageManifestContractParams) { + const packagePath = `extensions/${params.pluginId}/package.json`; + + describe(`${params.pluginId} package manifest contract`, () => { + if (params.runtimeDeps?.length) { + for (const dependencyName of params.runtimeDeps) { + it(`keeps ${dependencyName} plugin-local`, () => { + const rootManifest = readJson("package.json"); + const pluginManifest = readJson(packagePath); + const pluginSpec = pluginManifest.dependencies?.[dependencyName]; + const rootSpec = rootManifest.dependencies?.[dependencyName]; + + expect(pluginSpec).toBeTruthy(); + expect(rootSpec).toBeUndefined(); + }); + } + } + + const minHostVersionBaseline = params.minHostVersionBaseline; + if (minHostVersionBaseline) { + it("declares a parseable minHostVersion floor at or above the baseline", () => { + const baseline = parseSemver(minHostVersionBaseline); + expect(baseline).not.toBeNull(); + if (!baseline) { + return; + } + + const manifest = readJson(packagePath); + const requirement = parseMinHostVersionRequirement( + manifest.openclaw?.install?.minHostVersion ?? null, + ); + + expect( + requirement, + `${packagePath} should declare openclaw.install.minHostVersion`, + ).not.toBeNull(); + if (!requirement) { + return; + } + + const minimum = parseSemver(requirement.minimumLabel); + expect(minimum, `${packagePath} should use a parseable semver floor`).not.toBeNull(); + if (!minimum) { + return; + } + + expect( + isAtLeast(minimum, baseline), + `${packagePath} should require at least OpenClaw ${minHostVersionBaseline}`, + ).toBe(true); + }); + } + }); +} diff --git a/test/helpers/extensions/plugin-registration-contract.ts b/test/helpers/extensions/plugin-registration-contract.ts new file mode 100644 index 00000000000..eb4f6482cad --- /dev/null +++ b/test/helpers/extensions/plugin-registration-contract.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { + imageGenerationProviderContractRegistry, + mediaUnderstandingProviderContractRegistry, + pluginRegistrationContractRegistry, + speechProviderContractRegistry, +} from "../../../src/plugins/contracts/registry.js"; +import { loadPluginManifestRegistry } from "../../../src/plugins/manifest-registry.js"; + +type PluginRegistrationContractParams = { + pluginId: string; + providerIds?: string[]; + webSearchProviderIds?: string[]; + speechProviderIds?: string[]; + mediaUnderstandingProviderIds?: string[]; + imageGenerationProviderIds?: string[]; + cliBackendIds?: string[]; + toolNames?: string[]; + requireSpeechVoices?: boolean; + requireDescribeImages?: boolean; + requireGenerateImage?: boolean; + manifestAuthChoice?: { + pluginId: string; + choiceId: string; + choiceLabel: string; + groupId: string; + groupLabel: string; + groupHint: string; + }; +}; + +function findRegistration(pluginId: string) { + const entry = pluginRegistrationContractRegistry.find( + (candidate) => candidate.pluginId === pluginId, + ); + if (!entry) { + throw new Error(`plugin registration contract missing for ${pluginId}`); + } + return entry; +} + +function findSpeechProviderIds(pluginId: string) { + return speechProviderContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +function findSpeechProvider(pluginId: string) { + const entry = speechProviderContractRegistry.find((candidate) => candidate.pluginId === pluginId); + if (!entry) { + throw new Error(`speech provider contract missing for ${pluginId}`); + } + return entry.provider; +} + +function findMediaUnderstandingProviderIds(pluginId: string) { + return mediaUnderstandingProviderContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +function findMediaUnderstandingProvider(pluginId: string) { + const entry = mediaUnderstandingProviderContractRegistry.find( + (candidate) => candidate.pluginId === pluginId, + ); + if (!entry) { + throw new Error(`media-understanding provider contract missing for ${pluginId}`); + } + return entry.provider; +} + +function findImageGenerationProviderIds(pluginId: string) { + return imageGenerationProviderContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +function findImageGenerationProvider(pluginId: string) { + const entry = imageGenerationProviderContractRegistry.find( + (candidate) => candidate.pluginId === pluginId, + ); + if (!entry) { + throw new Error(`image-generation provider contract missing for ${pluginId}`); + } + return entry.provider; +} + +export function describePluginRegistrationContract(params: PluginRegistrationContractParams) { + describe(`${params.pluginId} plugin registration contract`, () => { + if (params.providerIds) { + it("keeps bundled provider ownership explicit", () => { + expect(findRegistration(params.pluginId).providerIds).toEqual(params.providerIds); + }); + } + + if (params.webSearchProviderIds) { + it("keeps bundled web search ownership explicit", () => { + expect(findRegistration(params.pluginId).webSearchProviderIds).toEqual( + params.webSearchProviderIds, + ); + }); + } + + if (params.speechProviderIds) { + it("keeps bundled speech ownership explicit", () => { + expect(findRegistration(params.pluginId).speechProviderIds).toEqual( + params.speechProviderIds, + ); + expect(findSpeechProviderIds(params.pluginId)).toEqual(params.speechProviderIds); + }); + } + + if (params.mediaUnderstandingProviderIds) { + it("keeps bundled media-understanding ownership explicit", () => { + expect(findRegistration(params.pluginId).mediaUnderstandingProviderIds).toEqual( + params.mediaUnderstandingProviderIds, + ); + expect(findMediaUnderstandingProviderIds(params.pluginId)).toEqual( + params.mediaUnderstandingProviderIds, + ); + }); + } + + if (params.imageGenerationProviderIds) { + it("keeps bundled image-generation ownership explicit", () => { + expect(findRegistration(params.pluginId).imageGenerationProviderIds).toEqual( + params.imageGenerationProviderIds, + ); + expect(findImageGenerationProviderIds(params.pluginId)).toEqual( + params.imageGenerationProviderIds, + ); + }); + } + + if (params.cliBackendIds) { + it("keeps bundled CLI backend ownership explicit", () => { + expect(findRegistration(params.pluginId).cliBackendIds).toEqual(params.cliBackendIds); + }); + } + + if (params.toolNames) { + it("keeps bundled tool ownership explicit", () => { + expect(findRegistration(params.pluginId).toolNames).toEqual(params.toolNames); + }); + } + + if (params.requireSpeechVoices) { + it("keeps bundled speech voice-list support explicit", () => { + expect(findSpeechProvider(params.pluginId).listVoices).toEqual(expect.any(Function)); + }); + } + + if (params.requireDescribeImages) { + it("keeps bundled multi-image support explicit", () => { + expect(findMediaUnderstandingProvider(params.pluginId).describeImages).toEqual( + expect.any(Function), + ); + }); + } + + if (params.requireGenerateImage) { + it("keeps bundled image-generation support explicit", () => { + expect(findImageGenerationProvider(params.pluginId).generateImage).toEqual( + expect.any(Function), + ); + }); + } + + const manifestAuthChoice = params.manifestAuthChoice; + if (manifestAuthChoice) { + it("keeps onboarding auth grouping explicit", () => { + const plugin = loadPluginManifestRegistry({}).plugins.find( + (entry) => entry.origin === "bundled" && entry.id === manifestAuthChoice.pluginId, + ); + + expect(plugin?.providerAuthChoices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + choiceId: manifestAuthChoice.choiceId, + choiceLabel: manifestAuthChoice.choiceLabel, + groupId: manifestAuthChoice.groupId, + groupLabel: manifestAuthChoice.groupLabel, + groupHint: manifestAuthChoice.groupHint, + }), + ]), + ); + }); + } + }); +} diff --git a/test/helpers/extensions/provider-auth-contract.ts b/test/helpers/extensions/provider-auth-contract.ts new file mode 100644 index 00000000000..7380a0f77c0 --- /dev/null +++ b/test/helpers/extensions/provider-auth-contract.ts @@ -0,0 +1,378 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles/store.js"; +import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js"; +import { registerProviders, requireProvider } from "../../../src/plugins/contracts/testkit.js"; +import { createNonExitingRuntime } from "../../../src/runtime.js"; +import type { + WizardMultiSelectParams, + WizardPrompter, + WizardProgress, + WizardSelectParams, +} from "../../../src/wizard/prompts.js"; + +type LoginOpenAICodexOAuth = + (typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"]; +type GithubCopilotLoginCommand = + (typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"]; +type CreateVpsAwareHandlers = + (typeof import("../../../src/plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; +type EnsureAuthProfileStore = + typeof import("openclaw/plugin-sdk/agent-runtime").ensureAuthProfileStore; +type ListProfilesForProvider = + typeof import("openclaw/plugin-sdk/agent-runtime").listProfilesForProvider; + +const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn()); +const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); +const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); +const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, + githubCopilotLoginCommand: githubCopilotLoginCommandMock, + }; +}); + +vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureAuthProfileStore: ensureAuthProfileStoreMock, + listProfilesForProvider: listProfilesForProviderMock, + }; +}); + +import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; +import openAIPlugin from "../../../extensions/openai/index.js"; + +function buildPrompter(): WizardPrompter { + const progress: WizardProgress = { + update() {}, + stop() {}, + }; + return { + intro: async () => {}, + outro: async () => {}, + note: async () => {}, + select: async (params: WizardSelectParams) => { + const option = params.options[0]; + if (!option) { + throw new Error("missing select option"); + } + return option.value; + }, + multiselect: async (params: WizardMultiSelectParams) => params.initialValues ?? [], + text: async () => "", + confirm: async () => false, + progress: () => progress, + }; +} + +function buildAuthContext() { + return { + config: {}, + prompter: buildPrompter(), + runtime: createNonExitingRuntime(), + isRemote: false, + openUrl: async () => {}, + oauth: { + createVpsAwareHandlers: vi.fn(), + }, + }; +} + +function createJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +function buildOpenAICodexOAuthResult(params: { + profileId: string; + access: string; + refresh: string; + expires: number; + email?: string; +}) { + return { + profiles: [ + { + profileId: params.profileId, + credential: { + type: "oauth" as const, + provider: "openai-codex", + access: params.access, + refresh: params.refresh, + expires: params.expires, + ...(params.email ? { email: params.email } : {}), + }, + }, + ], + configPatch: { + agents: { + defaults: { + models: { + "openai-codex/gpt-5.4": {}, + }, + }, + }, + }, + defaultModel: "openai-codex/gpt-5.4", + notes: undefined, + }; +} + +function installSharedAuthProfileStoreHooks(state: { authStore: AuthProfileStore }) { + beforeEach(() => { + state.authStore = { version: 1, profiles: {} }; + ensureAuthProfileStoreMock.mockReset(); + ensureAuthProfileStoreMock.mockImplementation(() => state.authStore); + listProfilesForProviderMock.mockReset(); + listProfilesForProviderMock.mockImplementation((store, providerId) => + Object.entries(store.profiles) + .filter(([, credential]) => credential?.provider === providerId) + .map(([profileId]) => profileId), + ); + }); + + afterEach(() => { + loginOpenAICodexOAuthMock.mockReset(); + githubCopilotLoginCommandMock.mockReset(); + ensureAuthProfileStoreMock.mockReset(); + listProfilesForProviderMock.mockReset(); + clearRuntimeAuthProfileStoreSnapshots(); + }); +} + +export function describeOpenAICodexProviderAuthContract() { + const state = { + authStore: { version: 1, profiles: {} } as AuthProfileStore, + }; + + describe("openai-codex provider auth contract", () => { + installSharedAuthProfileStoreHooks(state); + + async function expectStableFallbackProfile(params: { access: string; profileId: string }) { + const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); + loginOpenAICodexOAuthMock.mockResolvedValueOnce({ + refresh: "refresh-token", + access: params.access, + expires: 1_700_000_000_000, + }); + const result = await provider.auth[0]?.run(buildAuthContext() as never); + expect(result).toEqual( + buildOpenAICodexOAuthResult({ + profileId: params.profileId, + access: params.access, + refresh: "refresh-token", + expires: 1_700_000_000_000, + }), + ); + } + + function getProvider() { + return requireProvider(registerProviders(openAIPlugin), "openai-codex"); + } + + it("keeps OAuth auth results provider-owned", async () => { + const provider = getProvider(); + loginOpenAICodexOAuthMock.mockResolvedValueOnce({ + email: "user@example.com", + refresh: "refresh-token", + access: "access-token", + expires: 1_700_000_000_000, + }); + + const result = await provider.auth[0]?.run(buildAuthContext() as never); + + expect(result).toEqual( + buildOpenAICodexOAuthResult({ + profileId: "openai-codex:user@example.com", + access: "access-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + email: "user@example.com", + }), + ); + }); + + it("backfills OAuth email from the JWT profile claim", async () => { + const provider = getProvider(); + const access = createJwt({ + "https://api.openai.com/profile": { + email: "jwt-user@example.com", + }, + }); + loginOpenAICodexOAuthMock.mockResolvedValueOnce({ + refresh: "refresh-token", + access, + expires: 1_700_000_000_000, + }); + + const result = await provider.auth[0]?.run(buildAuthContext() as never); + + expect(result).toEqual( + buildOpenAICodexOAuthResult({ + profileId: "openai-codex:jwt-user@example.com", + access, + refresh: "refresh-token", + expires: 1_700_000_000_000, + email: "jwt-user@example.com", + }), + ); + }); + + it("uses a stable fallback id when JWT email is missing", async () => { + const access = createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_user_id: "user-123__acct-456", + }, + }); + const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url"); + await expectStableFallbackProfile({ + access, + profileId: `openai-codex:id-${expectedStableId}`, + }); + }); + + it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => { + const access = createJwt({ + iss: "https://accounts.openai.com", + sub: "user-abc", + }); + const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString( + "base64url", + ); + await expectStableFallbackProfile({ + access, + profileId: `openai-codex:id-${expectedStableId}`, + }); + }); + + it("uses sub alone to build a stable fallback id when iss is missing", async () => { + const access = createJwt({ + sub: "user-abc", + }); + const expectedStableId = Buffer.from("user-abc").toString("base64url"); + await expectStableFallbackProfile({ + access, + profileId: `openai-codex:id-${expectedStableId}`, + }); + }); + + it("falls back to the default profile when JWT parsing yields no identity", async () => { + const provider = getProvider(); + loginOpenAICodexOAuthMock.mockResolvedValueOnce({ + refresh: "refresh-token", + access: "not-a-jwt-token", + expires: 1_700_000_000_000, + }); + + const result = await provider.auth[0]?.run(buildAuthContext() as never); + + expect(result).toEqual( + buildOpenAICodexOAuthResult({ + profileId: "openai-codex:default", + access: "not-a-jwt-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + }), + ); + }); + + it("keeps OAuth failures non-fatal at the provider layer", async () => { + const provider = getProvider(); + loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed")); + + await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({ + profiles: [], + }); + }); + }); +} + +export function describeGithubCopilotProviderAuthContract() { + const state = { + authStore: { version: 1, profiles: {} } as AuthProfileStore, + }; + + describe("github-copilot provider auth contract", () => { + installSharedAuthProfileStoreHooks(state); + + function getProvider() { + return requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); + } + + it("keeps device auth results provider-owned", async () => { + const provider = getProvider(); + state.authStore.profiles["github-copilot:github"] = { + type: "token", + provider: "github-copilot", + token: "github-device-token", + }; + + const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; + const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); + const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); + Object.defineProperty(stdin, "isTTY", { + configurable: true, + enumerable: true, + get: () => true, + }); + + try { + const result = await provider.auth[0]?.run(buildAuthContext() as never); + expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith( + { yes: true, profileId: "github-copilot:github" }, + expect.any(Object), + ); + expect(result).toEqual({ + profiles: [ + { + profileId: "github-copilot:github", + credential: { + type: "token", + provider: "github-copilot", + token: "github-device-token", + }, + }, + ], + defaultModel: "github-copilot/gpt-4o", + }); + } finally { + if (previousIsTTYDescriptor) { + Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); + } else if (!hadOwnIsTTY) { + delete (stdin as { isTTY?: boolean }).isTTY; + } + } + }); + + it("keeps auth gated on interactive TTYs", async () => { + const provider = getProvider(); + const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; + const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); + const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); + Object.defineProperty(stdin, "isTTY", { + configurable: true, + enumerable: true, + get: () => false, + }); + + try { + await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({ + profiles: [], + }); + expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); + } finally { + if (previousIsTTYDescriptor) { + Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); + } else if (!hadOwnIsTTY) { + delete (stdin as { isTTY?: boolean }).isTTY; + } + } + }); + }); +} diff --git a/test/helpers/extensions/provider-catalog-contract.ts b/test/helpers/extensions/provider-catalog-contract.ts new file mode 100644 index 00000000000..d9e6ccbfd06 --- /dev/null +++ b/test/helpers/extensions/provider-catalog-contract.ts @@ -0,0 +1,108 @@ +import { beforeAll, beforeEach, describe, it, vi } from "vitest"; +import { + expectAugmentedCodexCatalog, + expectCodexBuiltInSuppression, + expectCodexMissingAuthHint, +} from "../../../src/plugins/provider-runtime.test-support.js"; +import type { ProviderPlugin } from "../../../src/plugins/types.js"; +import { registerProviderPlugin, requireRegisteredProvider } from "./provider-registration.js"; + +const PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS = 300_000; + +type ResolvePluginProviders = + typeof import("../../../src/plugins/providers.runtime.js").resolvePluginProviders; +type ResolveOwningPluginIdsForProvider = + typeof import("../../../src/plugins/providers.js").resolveOwningPluginIdsForProvider; +type ResolveCatalogHookProviderPluginIds = + typeof import("../../../src/plugins/providers.js").resolveCatalogHookProviderPluginIds; + +const resolvePluginProvidersMock = vi.hoisted(() => vi.fn(() => [])); +const resolveOwningPluginIdsForProviderMock = vi.hoisted(() => + vi.fn(() => undefined), +); +const resolveCatalogHookProviderPluginIdsMock = vi.hoisted(() => + vi.fn((_) => [] as string[]), +); + +vi.mock("../../../src/plugins/providers.js", () => ({ + resolveOwningPluginIdsForProvider: (params: unknown) => + resolveOwningPluginIdsForProviderMock(params as never), + resolveCatalogHookProviderPluginIds: (params: unknown) => + resolveCatalogHookProviderPluginIdsMock(params as never), +})); + +vi.mock("../../../src/plugins/providers.runtime.js", () => ({ + resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never), +})); + +export function describeOpenAIProviderCatalogContract() { + let augmentModelCatalogWithProviderPlugins: typeof import("../../../src/plugins/provider-runtime.js").augmentModelCatalogWithProviderPlugins; + let resetProviderRuntimeHookCacheForTest: typeof import("../../../src/plugins/provider-runtime.js").resetProviderRuntimeHookCacheForTest; + let resolveProviderBuiltInModelSuppression: typeof import("../../../src/plugins/provider-runtime.js").resolveProviderBuiltInModelSuppression; + let openaiProviders: ProviderPlugin[]; + let openaiProvider: ProviderPlugin; + + describe( + "openai provider catalog contract", + { timeout: PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS }, + () => { + beforeAll(async () => { + vi.resetModules(); + const openaiPlugin = await import("../../../extensions/openai/index.ts"); + openaiProviders = registerProviderPlugin({ + plugin: openaiPlugin.default, + id: "openai", + name: "OpenAI", + }).providers; + openaiProvider = requireRegisteredProvider(openaiProviders, "openai", "provider"); + ({ + augmentModelCatalogWithProviderPlugins, + resetProviderRuntimeHookCacheForTest, + resolveProviderBuiltInModelSuppression, + } = await import("../../../src/plugins/provider-runtime.js")); + }); + + beforeEach(() => { + resetProviderRuntimeHookCacheForTest(); + + resolvePluginProvidersMock.mockReset(); + resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { + const onlyPluginIds = params?.onlyPluginIds; + if (!onlyPluginIds || onlyPluginIds.length === 0) { + return openaiProviders; + } + return onlyPluginIds.includes("openai") ? openaiProviders : []; + }); + + resolveOwningPluginIdsForProviderMock.mockReset(); + resolveOwningPluginIdsForProviderMock.mockImplementation((params) => { + switch (params.provider) { + case "azure-openai-responses": + case "openai": + case "openai-codex": + return ["openai"]; + default: + return undefined; + } + }); + + resolveCatalogHookProviderPluginIdsMock.mockReset(); + resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]); + }); + + it("keeps codex-only missing-auth hints wired through the provider runtime", () => { + expectCodexMissingAuthHint( + (params) => openaiProvider.buildMissingAuthMessage?.(params.context) ?? undefined, + ); + }); + + it("keeps built-in model suppression wired through the provider runtime", () => { + expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression); + }); + + it("keeps bundled model augmentation wired through the provider runtime", async () => { + await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins); + }); + }, + ); +} diff --git a/test/helpers/extensions/provider-contract.ts b/test/helpers/extensions/provider-contract.ts new file mode 100644 index 00000000000..f01bb6f3e65 --- /dev/null +++ b/test/helpers/extensions/provider-contract.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + providerContractLoadError, + resolveProviderContractProvidersForPluginIds, +} from "../../../src/plugins/contracts/registry.js"; +import { installProviderPluginContractSuite } from "../../../src/plugins/contracts/suites.js"; + +export function describeProviderContracts(pluginId: string) { + const providers = resolveProviderContractProvidersForPluginIds([pluginId]); + + describe(`${pluginId} provider contract registry load`, () => { + it("loads bundled providers without import-time registry failure", () => { + expect(providerContractLoadError).toBeUndefined(); + expect(providers.length).toBeGreaterThan(0); + }); + }); + + for (const provider of providers) { + describe(`${pluginId}:${provider.id} provider contract`, () => { + installProviderPluginContractSuite({ provider }); + }); + } +} diff --git a/test/helpers/extensions/provider-discovery-contract.ts b/test/helpers/extensions/provider-discovery-contract.ts new file mode 100644 index 00000000000..38522d7cee6 --- /dev/null +++ b/test/helpers/extensions/provider-discovery-contract.ts @@ -0,0 +1,644 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { ModelDefinitionConfig } from "../../../src/config/types.models.js"; +import { registerProviders, requireProvider } from "../../../src/plugins/contracts/testkit.js"; + +const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); +const buildOllamaProviderMock = vi.hoisted(() => vi.fn()); +const buildVllmProviderMock = vi.hoisted(() => vi.fn()); +const buildSglangProviderMock = vi.hoisted(() => vi.fn()); +const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); +const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); + +type ProviderHandle = Awaited>; + +type DiscoveryState = { + runProviderCatalog: typeof import("../../../src/plugins/provider-discovery.js").runProviderCatalog; + githubCopilotProvider?: ProviderHandle; + ollamaProvider?: ProviderHandle; + vllmProvider?: ProviderHandle; + sglangProvider?: ProviderHandle; + minimaxProvider?: ProviderHandle; + minimaxPortalProvider?: ProviderHandle; + modelStudioProvider?: ProviderHandle; + cloudflareAiGatewayProvider?: ProviderHandle; +}; + +function createModelConfig(id: string, name = id): ModelDefinitionConfig { + return { + id, + name, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128_000, + maxTokens: 8_192, + }; +} + +function setRuntimeAuthStore(store?: AuthProfileStore) { + const resolvedStore = store ?? { + version: 1, + profiles: {}, + }; + ensureAuthProfileStoreMock.mockReturnValue(resolvedStore); + listProfilesForProviderMock.mockImplementation( + (authStore: AuthProfileStore, providerId: string) => + Object.entries(authStore.profiles) + .filter(([, credential]) => credential.provider === providerId) + .map(([profileId]) => profileId), + ); +} + +function setGithubCopilotProfileSnapshot() { + setRuntimeAuthStore({ + version: 1, + profiles: { + "github-copilot:github": { + type: "token", + provider: "github-copilot", + token: "profile-token", + }, + }, + }); +} + +function runCatalog( + state: DiscoveryState, + params: { + provider: ProviderHandle; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + resolveProviderApiKey?: () => { apiKey: string | undefined }; + resolveProviderAuth?: ( + providerId?: string, + options?: { oauthMarker?: string }, + ) => { + apiKey: string | undefined; + discoveryApiKey?: string; + mode: "api_key" | "oauth" | "token" | "none"; + source: "env" | "profile" | "none"; + profileId?: string; + }; + }, +) { + return state.runProviderCatalog({ + provider: params.provider, + config: params.config ?? {}, + env: params.env ?? ({} as NodeJS.ProcessEnv), + resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })), + resolveProviderAuth: + params.resolveProviderAuth ?? + ((_, options) => ({ + apiKey: options?.oauthMarker, + discoveryApiKey: undefined, + mode: options?.oauthMarker ? "oauth" : "none", + source: options?.oauthMarker ? "profile" : "none", + })), + }); +} + +function installDiscoveryHooks(state: DiscoveryState) { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => { + const actual = await import("../../../src/plugin-sdk/agent-runtime.ts"); + return { + ...actual, + ensureAuthProfileStore: ensureAuthProfileStoreMock, + listProfilesForProvider: listProfilesForProviderMock, + }; + }); + vi.doMock("openclaw/plugin-sdk/provider-auth", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/provider-auth"); + return { + ...actual, + ensureAuthProfileStore: ensureAuthProfileStoreMock, + listProfilesForProvider: listProfilesForProviderMock, + }; + }); + vi.doMock("../../../extensions/github-copilot/token.js", async () => { + const actual = await vi.importActual("../../../extensions/github-copilot/token.js"); + return { + ...actual, + resolveCopilotApiToken: resolveCopilotApiTokenMock, + }; + }); + vi.doMock("openclaw/plugin-sdk/provider-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/provider-setup"); + return { + ...actual, + buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), + buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), + buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), + }; + }); + vi.doMock("openclaw/plugin-sdk/self-hosted-provider-setup", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/self-hosted-provider-setup", + ); + return { + ...actual, + buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), + buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), + }; + }); + ({ runProviderCatalog: state.runProviderCatalog } = + await import("../../../src/plugins/provider-discovery.js")); + const [ + { default: githubCopilotPlugin }, + { default: ollamaPlugin }, + { default: vllmPlugin }, + { default: sglangPlugin }, + { default: minimaxPlugin }, + { default: modelStudioPlugin }, + { default: cloudflareAiGatewayPlugin }, + ] = await Promise.all([ + import("../../../extensions/github-copilot/index.js"), + import("../../../extensions/ollama/index.js"), + import("../../../extensions/vllm/index.js"), + import("../../../extensions/sglang/index.js"), + import("../../../extensions/minimax/index.js"), + import("../../../extensions/modelstudio/index.js"), + import("../../../extensions/cloudflare-ai-gateway/index.js"), + ]); + state.githubCopilotProvider = requireProvider( + registerProviders(githubCopilotPlugin), + "github-copilot", + ); + state.ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama"); + state.vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm"); + state.sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang"); + state.minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax"); + state.minimaxPortalProvider = requireProvider( + registerProviders(minimaxPlugin), + "minimax-portal", + ); + state.modelStudioProvider = requireProvider( + registerProviders(modelStudioPlugin), + "modelstudio", + ); + state.cloudflareAiGatewayProvider = requireProvider( + registerProviders(cloudflareAiGatewayPlugin), + "cloudflare-ai-gateway", + ); + setRuntimeAuthStore(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resolveCopilotApiTokenMock.mockReset(); + buildOllamaProviderMock.mockReset(); + buildVllmProviderMock.mockReset(); + buildSglangProviderMock.mockReset(); + ensureAuthProfileStoreMock.mockReset(); + listProfilesForProviderMock.mockReset(); + }); +} + +export function describeGithubCopilotProviderDiscoveryContract() { + const state = {} as DiscoveryState; + + describe("github-copilot provider discovery contract", () => { + installDiscoveryHooks(state); + + it("keeps catalog disabled without env tokens or profiles", async () => { + await expect( + runCatalog(state, { provider: state.githubCopilotProvider! }), + ).resolves.toBeNull(); + }); + + it("keeps profile-only catalog fallback provider-owned", async () => { + setGithubCopilotProfileSnapshot(); + + await expect( + runCatalog(state, { + provider: state.githubCopilotProvider!, + }), + ).resolves.toEqual({ + provider: { + baseUrl: "https://api.individual.githubcopilot.com", + models: [], + }, + }); + }); + + it("keeps env-token base URL resolution provider-owned", async () => { + resolveCopilotApiTokenMock.mockResolvedValueOnce({ + token: "copilot-api-token", + baseUrl: "https://copilot-proxy.example.com", + expiresAt: Date.now() + 60_000, + }); + + await expect( + runCatalog(state, { + provider: state.githubCopilotProvider!, + env: { + GITHUB_TOKEN: "github-env-token", + } as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: undefined }), + }), + ).resolves.toEqual({ + provider: { + baseUrl: "https://copilot-proxy.example.com", + models: [], + }, + }); + expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({ + githubToken: "github-env-token", + env: expect.objectContaining({ + GITHUB_TOKEN: "github-env-token", + }), + }); + }); + }); +} + +export function describeOllamaProviderDiscoveryContract() { + const state = {} as DiscoveryState; + + describe("ollama provider discovery contract", () => { + installDiscoveryHooks(state); + + it("keeps explicit catalog normalization provider-owned", async () => { + await expect( + state.runProviderCatalog({ + provider: state.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({ + provider: { + baseUrl: "http://ollama-host:11434", + api: "ollama", + apiKey: "ollama-local", + models: [createModelConfig("llama3.2")], + }, + }); + expect(buildOllamaProviderMock).not.toHaveBeenCalled(); + }); + + it("keeps empty autodiscovery disabled without keys or explicit config", async () => { + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }); + + await expect( + runCatalog(state, { + provider: state.ollamaProvider!, + config: {}, + env: {} as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), + }), + ).resolves.toBeNull(); + expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true }); + }); + }); +} + +export function describeVllmProviderDiscoveryContract() { + const state = {} as DiscoveryState; + + describe("vllm provider discovery contract", () => { + installDiscoveryHooks(state); + + it("keeps 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( + runCatalog(state, { + provider: state.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: { + 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", + }); + }); + }); +} + +export function describeSglangProviderDiscoveryContract() { + const state = {} as DiscoveryState; + + describe("sglang provider discovery contract", () => { + installDiscoveryHooks(state); + + it("keeps 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( + runCatalog(state, { + provider: state.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", + }), + }), + ).resolves.toEqual({ + provider: { + 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", + }); + }); + }); +} + +export function describeMinimaxProviderDiscoveryContract() { + const state = {} as DiscoveryState; + + describe("minimax provider discovery contract", () => { + installDiscoveryHooks(state); + + it("keeps API catalog provider-owned", async () => { + await expect( + state.runProviderCatalog({ + provider: state.minimaxProvider!, + config: {}, + env: { + MINIMAX_API_KEY: "minimax-key", + } as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: "minimax-key" }), + resolveProviderAuth: () => ({ + apiKey: "minimax-key", + discoveryApiKey: undefined, + mode: "api_key", + source: "env", + }), + }), + ).resolves.toMatchObject({ + provider: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + authHeader: true, + apiKey: "minimax-key", + models: expect.arrayContaining([ + expect.objectContaining({ id: "MiniMax-M2.7" }), + expect.objectContaining({ id: "MiniMax-M2.7-highspeed" }), + ]), + }, + }); + }); + + it("keeps portal oauth marker fallback provider-owned", async () => { + setRuntimeAuthStore({ + version: 1, + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }); + + await expect( + runCatalog(state, { + provider: state.minimaxPortalProvider!, + config: {}, + env: {} as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: "minimax-oauth", + discoveryApiKey: "access-token", + mode: "oauth", + source: "profile", + profileId: "minimax-portal:default", + }), + }), + ).resolves.toMatchObject({ + provider: { + baseUrl: "https://api.minimax.io/anthropic", + api: "anthropic-messages", + authHeader: true, + apiKey: "minimax-oauth", + models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.7" })]), + }, + }); + }); + + it("keeps portal explicit base URL override provider-owned", async () => { + await expect( + state.runProviderCatalog({ + provider: state.minimaxPortalProvider!, + config: { + models: { + providers: { + "minimax-portal": { + baseUrl: "https://portal-proxy.example.com/anthropic", + apiKey: "explicit-key", + models: [], + }, + }, + }, + }, + env: {} as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), + }), + ).resolves.toMatchObject({ + provider: { + baseUrl: "https://portal-proxy.example.com/anthropic", + apiKey: "explicit-key", + }, + }); + }); + }); +} + +export function describeModelStudioProviderDiscoveryContract() { + const state = {} as DiscoveryState; + + describe("modelstudio provider discovery contract", () => { + installDiscoveryHooks(state); + + it("keeps catalog provider-owned", async () => { + await expect( + state.runProviderCatalog({ + provider: state.modelStudioProvider!, + config: { + models: { + providers: { + modelstudio: { + baseUrl: "https://coding.dashscope.aliyuncs.com/v1", + models: [], + }, + }, + }, + }, + env: { + MODELSTUDIO_API_KEY: "modelstudio-key", + } as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }), + resolveProviderAuth: () => ({ + apiKey: "modelstudio-key", + discoveryApiKey: undefined, + mode: "api_key", + source: "env", + }), + }), + ).resolves.toMatchObject({ + provider: { + baseUrl: "https://coding.dashscope.aliyuncs.com/v1", + api: "openai-completions", + apiKey: "modelstudio-key", + models: expect.arrayContaining([ + expect.objectContaining({ id: "qwen3.5-plus" }), + expect.objectContaining({ id: "MiniMax-M2.5" }), + ]), + }, + }); + }); + }); +} + +export function describeCloudflareAiGatewayProviderDiscoveryContract() { + const state = {} as DiscoveryState; + + describe("cloudflare-ai-gateway provider discovery contract", () => { + installDiscoveryHooks(state); + + it("keeps catalog disabled without stored metadata", async () => { + await expect( + runCatalog(state, { + provider: state.cloudflareAiGatewayProvider!, + config: {}, + env: {} as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), + }), + ).resolves.toBeNull(); + }); + + it("keeps env-managed catalog provider-owned", async () => { + setRuntimeAuthStore({ + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + keyRef: { + source: "env", + provider: "default", + id: "CLOUDFLARE_AI_GATEWAY_API_KEY", + }, + metadata: { + accountId: "acc-123", + gatewayId: "gw-456", + }, + }, + }, + }); + + await expect( + runCatalog(state, { + provider: state.cloudflareAiGatewayProvider!, + config: {}, + env: { + CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value", + } as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), + }), + ).resolves.toEqual({ + provider: { + baseUrl: "https://gateway.ai.cloudflare.com/v1/acc-123/gw-456/anthropic", + api: "anthropic-messages", + apiKey: "CLOUDFLARE_AI_GATEWAY_API_KEY", + models: [expect.objectContaining({ id: "claude-sonnet-4-5" })], + }, + }); + }); + }); +} diff --git a/src/plugins/contracts/runtime.contract.test.ts b/test/helpers/extensions/provider-runtime-contract.ts similarity index 86% rename from src/plugins/contracts/runtime.contract.test.ts rename to test/helpers/extensions/provider-runtime-contract.ts index 4f77736e570..77c60ceed32 100644 --- a/src/plugins/contracts/runtime.contract.test.ts +++ b/test/helpers/extensions/provider-runtime-contract.ts @@ -2,21 +2,21 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderPlugin, ProviderRuntimeModel } from "../../../src/plugins/types.js"; import { - registerProviderPlugin, - requireRegisteredProvider, -} from "../../../test/helpers/extensions/provider-registration.js"; -import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js"; -import type { ProviderPlugin, ProviderRuntimeModel } from "../types.js"; + createProviderUsageFetch, + makeResponse, +} from "../../../src/test-utils/provider-usage-fetch.js"; +import { registerProviderPlugin, requireRegisteredProvider } from "./provider-registration.js"; const CONTRACT_SETUP_TIMEOUT_MS = 300_000; const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn()); const getOAuthProvidersMock = vi.hoisted(() => vi.fn(() => [ - { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret - { id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" }, // pragma: allowlist secret - { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret + { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, + { id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" }, + { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, ]), ); @@ -118,7 +118,7 @@ function requireProviderContractProvider(providerId: string): ProviderPlugin { return provider; } -describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { +function installRuntimeHooks() { beforeAll(async () => { providerRuntimeContractProviders.clear(); const registeredFixtures = await Promise.all( @@ -143,41 +143,42 @@ describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () } } }, CONTRACT_SETUP_TIMEOUT_MS); + beforeEach(() => { refreshOpenAICodexTokenMock.mockReset(); getOAuthProvidersMock.mockClear(); }, CONTRACT_SETUP_TIMEOUT_MS); +} - describe("anthropic", () => { - it( - "owns anthropic 4.6 forward-compat resolution", - () => { - const provider = requireProviderContractProvider("anthropic"); - const model = provider.resolveDynamicModel?.({ - provider: "anthropic", - modelId: "claude-sonnet-4.6-20260219", - modelRegistry: { - find: (_provider: string, id: string) => - id === "claude-sonnet-4.5-20260219" - ? createModel({ - id: id, - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - }) - : null, - } as never, - }); +export function describeAnthropicProviderRuntimeContract() { + describe("anthropic provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + installRuntimeHooks(); - expect(model).toMatchObject({ - id: "claude-sonnet-4.6-20260219", - provider: "anthropic", - api: "anthropic-messages", - baseUrl: "https://api.anthropic.com", - }); - }, - CONTRACT_SETUP_TIMEOUT_MS, - ); + it("owns anthropic 4.6 forward-compat resolution", () => { + const provider = requireProviderContractProvider("anthropic"); + const model = provider.resolveDynamicModel?.({ + provider: "anthropic", + modelId: "claude-sonnet-4.6-20260219", + modelRegistry: { + find: (_provider: string, id: string) => + id === "claude-sonnet-4.5-20260219" + ? createModel({ + id, + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + }) + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "claude-sonnet-4.6-20260219", + provider: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + }); + }); it("owns usage auth resolution", async () => { const provider = requireProviderContractProvider("anthropic"); @@ -260,35 +261,47 @@ describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () }); }); }); +} - describe("github-copilot", () => { - it("owns Copilot-specific forward-compat fallbacks", () => { - const provider = requireProviderContractProvider("github-copilot"); - const model = provider.resolveDynamicModel?.({ - provider: "github-copilot", - modelId: "gpt-5.4", - modelRegistry: { - find: (_provider: string, id: string) => - id === "gpt-5.2-codex" - ? createModel({ - id, - api: "openai-codex-responses", - provider: "github-copilot", - baseUrl: "https://api.copilot.example", - }) - : null, - } as never, +export function describeGithubCopilotProviderRuntimeContract() { + describe( + "github-copilot provider runtime contract", + { timeout: CONTRACT_SETUP_TIMEOUT_MS }, + () => { + installRuntimeHooks(); + + it("owns Copilot-specific forward-compat fallbacks", () => { + const provider = requireProviderContractProvider("github-copilot"); + const model = provider.resolveDynamicModel?.({ + provider: "github-copilot", + modelId: "gpt-5.4", + modelRegistry: { + find: (_provider: string, id: string) => + id === "gpt-5.2-codex" + ? createModel({ + id, + api: "openai-codex-responses", + provider: "github-copilot", + baseUrl: "https://api.copilot.example", + }) + : null, + } as never, + }); + + expect(model).toMatchObject({ + id: "gpt-5.4", + provider: "github-copilot", + api: "openai-codex-responses", + }); }); + }, + ); +} - expect(model).toMatchObject({ - id: "gpt-5.4", - provider: "github-copilot", - api: "openai-codex-responses", - }); - }); - }); +export function describeGoogleProviderRuntimeContract() { + describe("google provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + installRuntimeHooks(); - describe("google", () => { it("owns google direct gemini 3.1 forward-compat resolution", () => { const provider = requireProviderContractProvider("google"); const model = provider.resolveDynamicModel?.({ @@ -318,9 +331,7 @@ describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () reasoning: true, }); }); - }); - describe("google-gemini-cli", () => { it("owns gemini cli 3.1 forward-compat resolution", () => { const provider = requireProviderContractProvider("google-gemini-cli"); const model = provider.resolveDynamicModel?.({ @@ -415,8 +426,12 @@ describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () expect(snapshot?.windows[1]?.usedPercent).toBeCloseTo(20); }); }); +} + +export function describeOpenAIProviderRuntimeContract() { + describe("openai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + installRuntimeHooks(); - describe("openai", () => { it("owns openai gpt-5.4 forward-compat resolution", () => { const provider = requireProviderContractProvider("openai"); const model = provider.resolveDynamicModel?.({ @@ -497,144 +512,24 @@ describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () api: "openai-responses", }); }); - }); - describe("xai", () => { - it("owns Grok forward-compat resolution for newer fast models", () => { - const provider = requireProviderContractProvider("xai"); - const model = provider.resolveDynamicModel?.({ - provider: "xai", - modelId: "grok-4-1-fast-reasoning", - modelRegistry: { - find: () => null, - } as never, - providerConfig: { - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - }, - }); + it("owns refresh fallback for accountId extraction failures", async () => { + const provider = requireProviderContractProvider("openai-codex"); + const credential = { + type: "oauth" as const, + provider: "openai-codex", + access: "cached-access-token", + refresh: "refresh-token", + expires: Date.now() - 60_000, + }; - expect(model).toMatchObject({ - id: "grok-4-1-fast-reasoning", - provider: "xai", - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - contextWindow: 2_000_000, - }); + refreshOpenAICodexTokenMock.mockRejectedValueOnce( + new Error("Failed to extract accountId from token"), + ); + + await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(credential); }); - it("owns xai modern-model matching without accepting multi-agent ids", () => { - const provider = requireProviderContractProvider("xai"); - - expect( - provider.isModernModelRef?.({ - provider: "xai", - modelId: "grok-4-1-fast-reasoning", - } as never), - ).toBe(true); - expect( - provider.isModernModelRef?.({ - provider: "xai", - modelId: "grok-4.20-multi-agent-experimental-beta-0304", - } as never), - ).toBe(false); - }); - - it("owns direct xai compat flags on resolved models", () => { - const provider = requireProviderContractProvider("xai"); - - expect( - provider.normalizeResolvedModel?.({ - provider: "xai", - modelId: "grok-4-1-fast", - model: createModel({ - id: "grok-4-1-fast", - provider: "xai", - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - }), - } as never), - ).toMatchObject({ - compat: { - toolSchemaProfile: "xai", - nativeWebSearchTool: true, - toolCallArgumentsEncoding: "html-entities", - }, - }); - }); - }); - - describe("openrouter", () => { - it("owns xai downstream compat flags for x-ai routed models", () => { - const provider = requireProviderContractProvider("openrouter"); - expect( - provider.normalizeResolvedModel?.({ - provider: "openrouter", - modelId: "x-ai/grok-4-1-fast", - model: createModel({ - id: "x-ai/grok-4-1-fast", - provider: "openrouter", - api: "openai-completions", - baseUrl: "https://openrouter.ai/api/v1", - }), - }), - ).toMatchObject({ - compat: { - toolSchemaProfile: "xai", - nativeWebSearchTool: true, - toolCallArgumentsEncoding: "html-entities", - }, - }); - }); - }); - - describe("venice", () => { - it("owns xai downstream compat flags for grok-backed Venice models", () => { - const provider = requireProviderContractProvider("venice"); - expect( - provider.normalizeResolvedModel?.({ - provider: "venice", - modelId: "grok-41-fast", - model: createModel({ - id: "grok-41-fast", - provider: "venice", - api: "openai-completions", - baseUrl: "https://api.venice.ai/api/v1", - }), - }), - ).toMatchObject({ - compat: { - toolSchemaProfile: "xai", - nativeWebSearchTool: true, - toolCallArgumentsEncoding: "html-entities", - }, - }); - }); - }); - - describe("openai-codex", () => { - it( - "owns refresh fallback for accountId extraction failures", - { timeout: CONTRACT_SETUP_TIMEOUT_MS }, - async () => { - const provider = requireProviderContractProvider("openai-codex"); - const credential = { - type: "oauth" as const, - provider: "openai-codex", - access: "cached-access-token", - refresh: "refresh-token", - expires: Date.now() - 60_000, - }; - - refreshOpenAICodexTokenMock.mockRejectedValueOnce( - new Error("Failed to extract accountId from token"), - ); - - await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(credential); - }, - ); - it("owns forward-compat codex models", () => { const provider = requireProviderContractProvider("openai-codex"); const model = provider.resolveDynamicModel?.({ @@ -712,8 +607,138 @@ describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () }); }); }); +} + +export function describeXAIProviderRuntimeContract() { + describe("xai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + installRuntimeHooks(); + + it("owns Grok forward-compat resolution for newer fast models", () => { + const provider = requireProviderContractProvider("xai"); + const model = provider.resolveDynamicModel?.({ + provider: "xai", + modelId: "grok-4-1-fast-reasoning", + modelRegistry: { + find: () => null, + } as never, + providerConfig: { + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + }, + }); + + expect(model).toMatchObject({ + id: "grok-4-1-fast-reasoning", + provider: "xai", + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + contextWindow: 2_000_000, + }); + }); + + it("owns modern-model matching without accepting multi-agent ids", () => { + const provider = requireProviderContractProvider("xai"); + + expect( + provider.isModernModelRef?.({ + provider: "xai", + modelId: "grok-4-1-fast-reasoning", + } as never), + ).toBe(true); + expect( + provider.isModernModelRef?.({ + provider: "xai", + modelId: "grok-4.20-multi-agent-experimental-beta-0304", + } as never), + ).toBe(false); + }); + + it("owns direct xai compat flags on resolved models", () => { + const provider = requireProviderContractProvider("xai"); + + expect( + provider.normalizeResolvedModel?.({ + provider: "xai", + modelId: "grok-4-1-fast", + model: createModel({ + id: "grok-4-1-fast", + provider: "xai", + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + }), + } as never), + ).toMatchObject({ + compat: { + toolSchemaProfile: "xai", + nativeWebSearchTool: true, + toolCallArgumentsEncoding: "html-entities", + }, + }); + }); + }); +} + +export function describeOpenRouterProviderRuntimeContract() { + describe("openrouter provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + installRuntimeHooks(); + + it("owns xai downstream compat flags for x-ai routed models", () => { + const provider = requireProviderContractProvider("openrouter"); + expect( + provider.normalizeResolvedModel?.({ + provider: "openrouter", + modelId: "x-ai/grok-4-1-fast", + model: createModel({ + id: "x-ai/grok-4-1-fast", + provider: "openrouter", + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + }), + }), + ).toMatchObject({ + compat: { + toolSchemaProfile: "xai", + nativeWebSearchTool: true, + toolCallArgumentsEncoding: "html-entities", + }, + }); + }); + }); +} + +export function describeVeniceProviderRuntimeContract() { + describe("venice provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + installRuntimeHooks(); + + it("owns xai downstream compat flags for grok-backed Venice models", () => { + const provider = requireProviderContractProvider("venice"); + expect( + provider.normalizeResolvedModel?.({ + provider: "venice", + modelId: "grok-41-fast", + model: createModel({ + id: "grok-41-fast", + provider: "venice", + api: "openai-completions", + baseUrl: "https://api.venice.ai/api/v1", + }), + }), + ).toMatchObject({ + compat: { + toolSchemaProfile: "xai", + nativeWebSearchTool: true, + toolCallArgumentsEncoding: "html-entities", + }, + }); + }); + }); +} + +export function describeZAIProviderRuntimeContract() { + describe("zai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + installRuntimeHooks(); - describe("zai", () => { it("owns glm-5 forward-compat resolution", () => { const provider = requireProviderContractProvider("zai"); const model = provider.resolveDynamicModel?.({ @@ -828,4 +853,4 @@ describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () }); }); }); -}); +} diff --git a/test/helpers/extensions/web-search-provider-contract.ts b/test/helpers/extensions/web-search-provider-contract.ts new file mode 100644 index 00000000000..1e4b79ca9d7 --- /dev/null +++ b/test/helpers/extensions/web-search-provider-contract.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { webSearchProviderContractRegistry } from "../../../src/plugins/contracts/registry.js"; +import { installWebSearchProviderContractSuite } from "../../../src/plugins/contracts/suites.js"; + +export function describeWebSearchProviderContracts(pluginId: string) { + const providers = webSearchProviderContractRegistry.filter( + (entry) => entry.pluginId === pluginId, + ); + + describe(`${pluginId} web search provider contract registry load`, () => { + it("loads bundled web search providers", () => { + expect(providers.length).toBeGreaterThan(0); + }); + }); + + for (const entry of providers) { + describe(`${pluginId}:${entry.provider.id} web search contract`, () => { + installWebSearchProviderContractSuite({ + provider: entry.provider, + credentialValue: entry.credentialValue, + }); + }); + } +}