mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 01:21:36 +00:00
test: dedupe plugin contract suites
This commit is contained in:
@@ -23,6 +23,44 @@ vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({
|
||||
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
|
||||
}));
|
||||
|
||||
function createAuthChoiceProvider(params: {
|
||||
providerId: string;
|
||||
label: string;
|
||||
methodId: string;
|
||||
methodLabel: string;
|
||||
kind: "oauth" | "api_key" | "custom";
|
||||
}) {
|
||||
return {
|
||||
id: params.providerId,
|
||||
label: params.label,
|
||||
auth: [
|
||||
{
|
||||
id: params.methodId,
|
||||
label: params.methodLabel,
|
||||
hint:
|
||||
params.kind === "api_key"
|
||||
? "Paste key"
|
||||
: params.kind === "custom"
|
||||
? "No auth"
|
||||
: "Browser sign-in",
|
||||
kind: params.kind,
|
||||
run: runAuthMethodMock,
|
||||
},
|
||||
],
|
||||
} satisfies ProviderPlugin;
|
||||
}
|
||||
|
||||
async function expectPreferredProviderFallback(provider: ProviderPlugin) {
|
||||
resolvePluginProvidersMock.mockClear();
|
||||
resolvePluginProvidersMock.mockReturnValue([provider]);
|
||||
await expect(
|
||||
resolvePreferredProviderForAuthChoice({
|
||||
choice: buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default"),
|
||||
}),
|
||||
).resolves.toBe(provider.id);
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("provider auth-choice contract", () => {
|
||||
beforeEach(() => {
|
||||
resolvePluginProvidersMock.mockReset();
|
||||
@@ -57,69 +95,38 @@ describe("provider auth-choice contract", () => {
|
||||
|
||||
it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => {
|
||||
const pluginFallbackScenarios: ProviderPlugin[] = [
|
||||
{
|
||||
id: "demo-oauth-provider",
|
||||
createAuthChoiceProvider({
|
||||
providerId: "demo-oauth-provider",
|
||||
label: "Demo OAuth Provider",
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "OAuth",
|
||||
hint: "Browser sign-in",
|
||||
kind: "oauth",
|
||||
run: runAuthMethodMock,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "demo-browser-provider",
|
||||
methodId: "oauth",
|
||||
methodLabel: "OAuth",
|
||||
kind: "oauth",
|
||||
}),
|
||||
createAuthChoiceProvider({
|
||||
providerId: "demo-browser-provider",
|
||||
label: "Demo Browser Provider",
|
||||
auth: [
|
||||
{
|
||||
id: "portal",
|
||||
label: "Portal",
|
||||
hint: "Browser sign-in",
|
||||
kind: "oauth",
|
||||
run: runAuthMethodMock,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "demo-api-key-provider",
|
||||
methodId: "portal",
|
||||
methodLabel: "Portal",
|
||||
kind: "oauth",
|
||||
}),
|
||||
createAuthChoiceProvider({
|
||||
providerId: "demo-api-key-provider",
|
||||
label: "Demo API Key Provider",
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
label: "API key",
|
||||
hint: "Paste key",
|
||||
kind: "api_key",
|
||||
run: runAuthMethodMock,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "demo-local-provider",
|
||||
methodId: "api-key",
|
||||
methodLabel: "API key",
|
||||
kind: "api_key",
|
||||
}),
|
||||
createAuthChoiceProvider({
|
||||
providerId: "demo-local-provider",
|
||||
label: "Demo Local Provider",
|
||||
auth: [
|
||||
{
|
||||
id: "local",
|
||||
label: "Local",
|
||||
hint: "No auth",
|
||||
kind: "custom",
|
||||
run: runAuthMethodMock,
|
||||
},
|
||||
],
|
||||
},
|
||||
methodId: "local",
|
||||
methodLabel: "Local",
|
||||
kind: "custom",
|
||||
}),
|
||||
];
|
||||
|
||||
for (const provider of pluginFallbackScenarios) {
|
||||
resolvePluginProvidersMock.mockClear();
|
||||
resolvePluginProvidersMock.mockReturnValue([provider]);
|
||||
await expect(
|
||||
resolvePreferredProviderForAuthChoice({
|
||||
choice: buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default"),
|
||||
}),
|
||||
).resolves.toBe(provider.id);
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalled();
|
||||
await expectPreferredProviderFallback(provider);
|
||||
}
|
||||
|
||||
resolvePluginProvidersMock.mockClear();
|
||||
|
||||
@@ -64,6 +64,45 @@ function setGithubCopilotProfileSnapshot() {
|
||||
});
|
||||
}
|
||||
|
||||
function createNoAuthResolution() {
|
||||
return {
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none" as const,
|
||||
source: "none" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function createResolvedAuth(params: {
|
||||
apiKey: string | undefined;
|
||||
discoveryApiKey?: string;
|
||||
mode: "api_key" | "oauth" | "token" | "none";
|
||||
source: "env" | "profile" | "none";
|
||||
profileId?: string;
|
||||
}) {
|
||||
return {
|
||||
apiKey: params.apiKey,
|
||||
discoveryApiKey: params.discoveryApiKey,
|
||||
mode: params.mode,
|
||||
source: params.source,
|
||||
...(params.profileId ? { profileId: params.profileId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createNoAuthCatalogParams(
|
||||
provider: Awaited<ReturnType<typeof requireProvider>>,
|
||||
overrides: Partial<Parameters<typeof runProviderCatalog>[0]> = {},
|
||||
) {
|
||||
return {
|
||||
provider,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => createNoAuthResolution(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runCatalog(params: {
|
||||
provider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -202,7 +241,7 @@ describe("provider discovery contract", () => {
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => {
|
||||
await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull();
|
||||
await expect(runCatalog(createNoAuthCatalogParams(githubCopilotProvider))).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => {
|
||||
@@ -210,7 +249,7 @@ describe("provider discovery contract", () => {
|
||||
|
||||
await expect(
|
||||
runCatalog({
|
||||
provider: githubCopilotProvider,
|
||||
...createNoAuthCatalogParams(githubCopilotProvider),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
@@ -252,24 +291,17 @@ describe("provider discovery contract", () => {
|
||||
it("keeps Ollama explicit catalog normalization provider-owned", async () => {
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: ollamaProvider,
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434/v1/",
|
||||
models: [createModelConfig("llama3.2")],
|
||||
...createNoAuthCatalogParams(ollamaProvider, {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434/v1/",
|
||||
models: [createModelConfig("llama3.2")],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
@@ -290,98 +322,99 @@ describe("provider discovery contract", () => {
|
||||
models: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: ollamaProvider,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
await expect(runProviderCatalog(createNoAuthCatalogParams(ollamaProvider))).resolves.toBeNull();
|
||||
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
|
||||
});
|
||||
|
||||
it("keeps vLLM self-hosted discovery provider-owned", async () => {
|
||||
buildVllmProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: vllmProvider,
|
||||
config: {},
|
||||
env: {
|
||||
VLLM_API_KEY: "env-vllm-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "VLLM_API_KEY",
|
||||
discoveryApiKey: "env-vllm-key",
|
||||
}),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "VLLM_API_KEY",
|
||||
discoveryApiKey: "env-vllm-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
it.each([
|
||||
{
|
||||
name: "keeps vLLM self-hosted discovery provider-owned",
|
||||
provider: () => vllmProvider,
|
||||
buildProviderMock: buildVllmProviderMock,
|
||||
builtProvider: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "VLLM_API_KEY",
|
||||
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
|
||||
},
|
||||
});
|
||||
expect(buildVllmProviderMock).toHaveBeenCalledWith({
|
||||
apiKey: "env-vllm-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps SGLang self-hosted discovery provider-owned", async () => {
|
||||
buildSglangProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:30000/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: sglangProvider,
|
||||
config: {},
|
||||
env: {
|
||||
SGLANG_API_KEY: "env-sglang-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
discoveryApiKey: "env-sglang-key",
|
||||
}),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
discoveryApiKey: "env-sglang-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
env: {
|
||||
VLLM_API_KEY: "env-vllm-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolvedAuth: createResolvedAuth({
|
||||
apiKey: "VLLM_API_KEY",
|
||||
discoveryApiKey: "env-vllm-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
expected: {
|
||||
provider: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "VLLM_API_KEY",
|
||||
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
|
||||
},
|
||||
},
|
||||
expectedBuildCall: {
|
||||
apiKey: "env-vllm-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps SGLang self-hosted discovery provider-owned",
|
||||
provider: () => sglangProvider,
|
||||
buildProviderMock: buildSglangProviderMock,
|
||||
builtProvider: {
|
||||
baseUrl: "http://127.0.0.1:30000/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
|
||||
},
|
||||
});
|
||||
expect(buildSglangProviderMock).toHaveBeenCalledWith({
|
||||
apiKey: "env-sglang-key",
|
||||
});
|
||||
});
|
||||
env: {
|
||||
SGLANG_API_KEY: "env-sglang-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolvedAuth: createResolvedAuth({
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
discoveryApiKey: "env-sglang-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
expected: {
|
||||
provider: {
|
||||
baseUrl: "http://127.0.0.1:30000/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
|
||||
},
|
||||
},
|
||||
expectedBuildCall: {
|
||||
apiKey: "env-sglang-key",
|
||||
},
|
||||
},
|
||||
] as const)(
|
||||
"$name",
|
||||
async ({
|
||||
provider,
|
||||
buildProviderMock,
|
||||
builtProvider,
|
||||
env,
|
||||
resolvedAuth,
|
||||
expected,
|
||||
expectedBuildCall,
|
||||
}) => {
|
||||
buildProviderMock.mockResolvedValueOnce(builtProvider);
|
||||
|
||||
await expect(
|
||||
runProviderCatalog(
|
||||
createNoAuthCatalogParams(provider(), {
|
||||
env,
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: resolvedAuth.apiKey,
|
||||
discoveryApiKey: resolvedAuth.discoveryApiKey,
|
||||
}),
|
||||
resolveProviderAuth: () => resolvedAuth,
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual(expected);
|
||||
expect(buildProviderMock).toHaveBeenCalledWith(expectedBuildCall);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps MiniMax API catalog provider-owned", async () => {
|
||||
await expect(
|
||||
|
||||
@@ -26,6 +26,17 @@ function expectPluginAllowlistContains(
|
||||
}
|
||||
}
|
||||
|
||||
function createAllowlistCompatConfig(pluginIds: string[]) {
|
||||
return withBundledPluginAllowlistCompat({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: [demoAllowEntry],
|
||||
},
|
||||
},
|
||||
pluginIds,
|
||||
});
|
||||
}
|
||||
|
||||
const demoAllowEntry = "demo-allowed";
|
||||
|
||||
describe("plugin loader contract", () => {
|
||||
@@ -48,14 +59,7 @@ describe("plugin loader contract", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
compatConfig = withBundledPluginAllowlistCompat({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: [demoAllowEntry],
|
||||
},
|
||||
},
|
||||
pluginIds: compatPluginIds,
|
||||
});
|
||||
compatConfig = createAllowlistCompatConfig(compatPluginIds);
|
||||
vitestCompatConfig = providerTesting.withBundledProviderVitestCompat({
|
||||
config: undefined,
|
||||
pluginIds: providerPluginIds,
|
||||
@@ -65,14 +69,7 @@ describe("plugin loader contract", () => {
|
||||
resolveBundledPluginWebSearchProviders({}).map((entry) => entry.pluginId),
|
||||
);
|
||||
bundledWebSearchPluginIds = uniqueSortedStrings(resolveBundledWebSearchPluginIds({}));
|
||||
webSearchAllowlistCompatConfig = withBundledPluginAllowlistCompat({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: [demoAllowEntry],
|
||||
},
|
||||
},
|
||||
pluginIds: webSearchPluginIds,
|
||||
});
|
||||
webSearchAllowlistCompatConfig = createAllowlistCompatConfig(webSearchPluginIds);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -81,8 +78,9 @@ describe("plugin loader contract", () => {
|
||||
|
||||
it("keeps bundled provider compatibility wired to the provider registry", () => {
|
||||
expect(providerPluginIds).toEqual(manifestProviderPluginIds);
|
||||
expect(uniqueSortedStrings(compatPluginIds)).toEqual(manifestProviderPluginIds);
|
||||
expect(uniqueSortedStrings(compatPluginIds)).toEqual(expect.arrayContaining(providerPluginIds));
|
||||
const sortedCompatPluginIds = uniqueSortedStrings(compatPluginIds);
|
||||
expect(sortedCompatPluginIds).toEqual(manifestProviderPluginIds);
|
||||
expect(sortedCompatPluginIds).toEqual(expect.arrayContaining(providerPluginIds));
|
||||
expectPluginAllowlistContains(compatConfig?.plugins?.allow, providerPluginIds, demoAllowEntry);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,46 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getRegisteredMemoryEmbeddingProvider } from "../memory-embedding-providers.js";
|
||||
import { createPluginRegistry, type PluginRecord } from "../registry.js";
|
||||
import type { PluginRuntime } from "../runtime/types.js";
|
||||
import { createPluginRecord } from "../status.test-helpers.js";
|
||||
import type { OpenClawPluginApi } from "../types.js";
|
||||
|
||||
function registerTestPlugin(params: {
|
||||
registry: ReturnType<typeof createPluginRegistry>;
|
||||
config: OpenClawConfig;
|
||||
record: PluginRecord;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
params.registry.registry.plugins.push(params.record);
|
||||
params.register(
|
||||
params.registry.createApi(params.record, {
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
}
|
||||
import { createPluginRegistryFixture, registerVirtualTestPlugin } from "./testkit.js";
|
||||
|
||||
describe("memory embedding provider registration", () => {
|
||||
it("only allows memory plugins to register adapters", () => {
|
||||
const config = {} as OpenClawConfig;
|
||||
const registry = createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as PluginRuntime,
|
||||
});
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerTestPlugin({
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "not-memory",
|
||||
name: "Not Memory",
|
||||
source: "/virtual/not-memory/index.ts",
|
||||
}),
|
||||
id: "not-memory",
|
||||
name: "Not Memory",
|
||||
register(api) {
|
||||
api.registerMemoryEmbeddingProvider({
|
||||
id: "forbidden",
|
||||
@@ -61,26 +31,14 @@ describe("memory embedding provider registration", () => {
|
||||
});
|
||||
|
||||
it("records the owning memory plugin id for registered adapters", () => {
|
||||
const config = {} as OpenClawConfig;
|
||||
const registry = createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as PluginRuntime,
|
||||
});
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerTestPlugin({
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "memory-core",
|
||||
name: "Memory Core",
|
||||
kind: "memory",
|
||||
source: "/virtual/memory-core/index.ts",
|
||||
}),
|
||||
id: "memory-core",
|
||||
name: "Memory Core",
|
||||
kind: "memory",
|
||||
register(api) {
|
||||
api.registerMemoryEmbeddingProvider({
|
||||
id: "demo-embedding",
|
||||
|
||||
@@ -9,77 +9,99 @@ import {
|
||||
providerContractPluginIds,
|
||||
speechProviderContractRegistry,
|
||||
} from "./registry.js";
|
||||
import { uniqueSortedStrings } from "./testkit.js";
|
||||
|
||||
const REGISTRY_CONTRACT_TIMEOUT_MS = 300_000;
|
||||
|
||||
describe("plugin contract registry", () => {
|
||||
function expectUniqueIds(ids: readonly string[]) {
|
||||
expect(ids).toEqual([...new Set(ids)]);
|
||||
}
|
||||
|
||||
function expectRegistryPluginIds(params: {
|
||||
actualPluginIds: readonly string[];
|
||||
predicate: (plugin: {
|
||||
origin: string;
|
||||
providers: unknown[];
|
||||
contracts?: { speechProviders?: unknown[] };
|
||||
}) => boolean;
|
||||
}) {
|
||||
expect(uniqueSortedStrings(params.actualPluginIds)).toEqual(
|
||||
resolveBundledManifestPluginIds(params.predicate),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBundledManifestPluginIds(
|
||||
predicate: (plugin: {
|
||||
origin: string;
|
||||
providers: unknown[];
|
||||
contracts?: { speechProviders?: unknown[] };
|
||||
}) => boolean,
|
||||
) {
|
||||
return loadPluginManifestRegistry({})
|
||||
.plugins.filter(predicate)
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
it("loads bundled non-provider capability registries without import-time failure", () => {
|
||||
expect(providerContractLoadError).toBeUndefined();
|
||||
expect(pluginRegistrationContractRegistry.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not duplicate bundled provider ids", () => {
|
||||
const ids = pluginRegistrationContractRegistry.flatMap((entry) => entry.providerIds);
|
||||
expect(ids).toEqual([...new Set(ids)]);
|
||||
});
|
||||
|
||||
it("does not duplicate bundled web search provider ids", () => {
|
||||
const ids = pluginRegistrationContractRegistry.flatMap((entry) => entry.webSearchProviderIds);
|
||||
expect(ids).toEqual([...new Set(ids)]);
|
||||
it.each([
|
||||
{
|
||||
name: "does not duplicate bundled provider ids",
|
||||
ids: () => pluginRegistrationContractRegistry.flatMap((entry) => entry.providerIds),
|
||||
},
|
||||
{
|
||||
name: "does not duplicate bundled web search provider ids",
|
||||
ids: () => pluginRegistrationContractRegistry.flatMap((entry) => entry.webSearchProviderIds),
|
||||
},
|
||||
{
|
||||
name: "does not duplicate bundled media provider ids",
|
||||
ids: () => mediaUnderstandingProviderContractRegistry.map((entry) => entry.provider.id),
|
||||
},
|
||||
{
|
||||
name: "does not duplicate bundled image-generation provider ids",
|
||||
ids: () => imageGenerationProviderContractRegistry.map((entry) => entry.provider.id),
|
||||
},
|
||||
] as const)("$name", ({ ids }) => {
|
||||
expectUniqueIds(ids());
|
||||
});
|
||||
|
||||
it(
|
||||
"does not duplicate bundled speech provider ids",
|
||||
{ timeout: REGISTRY_CONTRACT_TIMEOUT_MS },
|
||||
() => {
|
||||
const ids = speechProviderContractRegistry.map((entry) => entry.provider.id);
|
||||
expect(ids).toEqual([...new Set(ids)]);
|
||||
expectUniqueIds(speechProviderContractRegistry.map((entry) => entry.provider.id));
|
||||
},
|
||||
);
|
||||
|
||||
it("does not duplicate bundled media provider ids", () => {
|
||||
const ids = mediaUnderstandingProviderContractRegistry.map((entry) => entry.provider.id);
|
||||
expect(ids).toEqual([...new Set(ids)]);
|
||||
});
|
||||
|
||||
it("covers every bundled provider plugin discovered from manifests", () => {
|
||||
const bundledProviderPluginIds = loadPluginManifestRegistry({})
|
||||
.plugins.filter((plugin) => plugin.origin === "bundled" && plugin.providers.length > 0)
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
expect(providerContractPluginIds).toEqual(bundledProviderPluginIds);
|
||||
expectRegistryPluginIds({
|
||||
actualPluginIds: providerContractPluginIds,
|
||||
predicate: (plugin) => plugin.origin === "bundled" && plugin.providers.length > 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("covers every bundled speech plugin discovered from manifests", () => {
|
||||
const bundledSpeechPluginIds = loadPluginManifestRegistry({})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.origin === "bundled" && (plugin.contracts?.speechProviders?.length ?? 0) > 0,
|
||||
)
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
expect(
|
||||
[...new Set(speechProviderContractRegistry.map((entry) => entry.pluginId))].toSorted(
|
||||
(left, right) => left.localeCompare(right),
|
||||
),
|
||||
).toEqual(bundledSpeechPluginIds);
|
||||
expectRegistryPluginIds({
|
||||
actualPluginIds: speechProviderContractRegistry.map((entry) => entry.pluginId),
|
||||
predicate: (plugin) =>
|
||||
plugin.origin === "bundled" && (plugin.contracts?.speechProviders?.length ?? 0) > 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("covers every bundled web search plugin from the shared resolver", () => {
|
||||
const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({});
|
||||
|
||||
expect(
|
||||
pluginRegistrationContractRegistry
|
||||
.filter((entry) => entry.webSearchProviderIds.length > 0)
|
||||
.map((entry) => entry.pluginId)
|
||||
.toSorted((left, right) => left.localeCompare(right)),
|
||||
uniqueSortedStrings(
|
||||
pluginRegistrationContractRegistry
|
||||
.filter((entry) => entry.webSearchProviderIds.length > 0)
|
||||
.map((entry) => entry.pluginId),
|
||||
),
|
||||
).toEqual(bundledWebSearchPluginIds);
|
||||
});
|
||||
|
||||
it("does not duplicate bundled image-generation provider ids", () => {
|
||||
const ids = imageGenerationProviderContractRegistry.map((entry) => entry.provider.id);
|
||||
expect(ids).toEqual([...new Set(ids)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createPluginRegistry, type PluginRecord } from "../registry.js";
|
||||
import type { PluginRuntime } from "../runtime/types.js";
|
||||
import { buildAllPluginInspectReports } from "../status.js";
|
||||
import { createPluginRecord } from "../status.test-helpers.js";
|
||||
import type { OpenClawPluginApi } from "../types.js";
|
||||
|
||||
function registerTestPlugin(params: {
|
||||
registry: ReturnType<typeof createPluginRegistry>;
|
||||
config: OpenClawConfig;
|
||||
record: PluginRecord;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
params.registry.registry.plugins.push(params.record);
|
||||
params.register(
|
||||
params.registry.createApi(params.record, {
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
}
|
||||
import { createPluginRegistryFixture, registerVirtualTestPlugin } from "./testkit.js";
|
||||
|
||||
describe("plugin shape compatibility matrix", () => {
|
||||
it("keeps legacy hook-only, plain capability, and hybrid capability shapes explicit", () => {
|
||||
const config = {} as OpenClawConfig;
|
||||
const registry = createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as PluginRuntime,
|
||||
});
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerTestPlugin({
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "lca-legacy",
|
||||
name: "LCA Legacy",
|
||||
source: "/virtual/lca-legacy/index.ts",
|
||||
}),
|
||||
id: "lca-legacy",
|
||||
name: "LCA Legacy",
|
||||
register(api) {
|
||||
api.on("before_agent_start", () => ({
|
||||
prependContext: "legacy",
|
||||
@@ -48,14 +18,11 @@ describe("plugin shape compatibility matrix", () => {
|
||||
},
|
||||
});
|
||||
|
||||
registerTestPlugin({
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "plain-provider",
|
||||
name: "Plain Provider",
|
||||
source: "/virtual/plain-provider/index.ts",
|
||||
}),
|
||||
id: "plain-provider",
|
||||
name: "Plain Provider",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "plain-provider",
|
||||
@@ -65,14 +32,11 @@ describe("plugin shape compatibility matrix", () => {
|
||||
},
|
||||
});
|
||||
|
||||
registerTestPlugin({
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "hybrid-company",
|
||||
name: "Hybrid Company",
|
||||
source: "/virtual/hybrid-company/index.ts",
|
||||
}),
|
||||
id: "hybrid-company",
|
||||
name: "Hybrid Company",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "hybrid-company",
|
||||
@@ -100,14 +64,11 @@ describe("plugin shape compatibility matrix", () => {
|
||||
},
|
||||
});
|
||||
|
||||
registerTestPlugin({
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "channel-demo",
|
||||
name: "Channel Demo",
|
||||
source: "/virtual/channel-demo/index.ts",
|
||||
}),
|
||||
id: "channel-demo",
|
||||
name: "Channel Demo",
|
||||
register(api) {
|
||||
api.registerChannel({
|
||||
plugin: {
|
||||
@@ -168,11 +129,8 @@ describe("plugin shape compatibility matrix", () => {
|
||||
]);
|
||||
|
||||
expect(inspect[0]?.usesLegacyBeforeAgentStart).toBe(true);
|
||||
expect(inspect[1]?.capabilities.map((entry) => entry.kind)).toEqual(["text-inference"]);
|
||||
expect(inspect[2]?.capabilities.map((entry) => entry.kind)).toEqual([
|
||||
"text-inference",
|
||||
"web-search",
|
||||
]);
|
||||
expect(inspect[3]?.capabilities.map((entry) => entry.kind)).toEqual(["channel"]);
|
||||
expect(inspect.map((entry) => entry.capabilities.map((capability) => capability.kind))).toEqual(
|
||||
[[], ["text-inference"], ["text-inference", "web-search"], ["channel"]],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createPluginRegistry, type PluginRecord } from "../registry.js";
|
||||
import type { PluginRuntime } from "../runtime/types.js";
|
||||
import { createPluginRecord } from "../status.test-helpers.js";
|
||||
import type { OpenClawPluginApi } from "../types.js";
|
||||
|
||||
export {
|
||||
registerProviderPlugins as registerProviders,
|
||||
requireRegisteredProvider as requireProvider,
|
||||
@@ -6,3 +12,54 @@ export {
|
||||
export function uniqueSortedStrings(values: readonly string[]) {
|
||||
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function createPluginRegistryFixture(config = {} as OpenClawConfig) {
|
||||
return {
|
||||
config,
|
||||
registry: createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as PluginRuntime,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTestPlugin(params: {
|
||||
registry: ReturnType<typeof createPluginRegistry>;
|
||||
config: OpenClawConfig;
|
||||
record: PluginRecord;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
params.registry.registry.plugins.push(params.record);
|
||||
params.register(
|
||||
params.registry.createApi(params.record, {
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerVirtualTestPlugin(params: {
|
||||
registry: ReturnType<typeof createPluginRegistry>;
|
||||
config: OpenClawConfig;
|
||||
id: string;
|
||||
name: string;
|
||||
source?: string;
|
||||
kind?: PluginRecord["kind"];
|
||||
register(this: void, api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
registerTestPlugin({
|
||||
registry: params.registry,
|
||||
config: params.config,
|
||||
record: createPluginRecord({
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
source: params.source ?? `/virtual/${params.id}/index.ts`,
|
||||
...(params.kind ? { kind: params.kind } : {}),
|
||||
}),
|
||||
register: params.register,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +107,16 @@ const mockAssistantMessage = (content: AssistantMessage["content"]): AssistantMe
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
function createSummarizeTextDeps() {
|
||||
return {
|
||||
completeSimple,
|
||||
getApiKeyForModel: getApiKeyForModelMock,
|
||||
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
|
||||
requireApiKey: requireApiKeyMock,
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenAiTelephonyCfg(model: "tts-1" | "gpt-4o-mini-tts"): OpenClawConfig {
|
||||
return {
|
||||
messages: {
|
||||
@@ -127,6 +137,23 @@ function createAudioBuffer(length = 2): Buffer {
|
||||
return Buffer.from(new Uint8Array(length).fill(1));
|
||||
}
|
||||
|
||||
async function withMockedSpeechFetch(
|
||||
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<void>,
|
||||
audioLength: number,
|
||||
) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(audioLength),
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
try {
|
||||
await run(fetchMock);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBaseUrl(rawValue: unknown, fallback: string): string {
|
||||
return typeof rawValue === "string" && rawValue.trim() ? rawValue.replace(/\/+$/u, "") : fallback;
|
||||
}
|
||||
@@ -450,30 +477,36 @@ describe("tts", () => {
|
||||
messages: { tts: {} },
|
||||
};
|
||||
|
||||
async function runSummarizeText(params?: {
|
||||
text?: string;
|
||||
targetLength?: number;
|
||||
cfg?: OpenClawConfig;
|
||||
}) {
|
||||
const cfg = params?.cfg ?? baseCfg;
|
||||
const config = resolveTtsConfig(cfg);
|
||||
return await summarizeText(
|
||||
{
|
||||
text: params?.text ?? "Long text to summarize",
|
||||
targetLength: params?.targetLength ?? 500,
|
||||
cfg,
|
||||
config,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
createSummarizeTextDeps(),
|
||||
);
|
||||
}
|
||||
|
||||
it("summarizes text and returns result with metrics", async () => {
|
||||
const mockSummary = "This is a summarized version of the text.";
|
||||
const baseConfig = resolveTtsConfig(baseCfg);
|
||||
vi.mocked(completeSimple).mockResolvedValue(
|
||||
mockAssistantMessage([{ type: "text", text: mockSummary }]),
|
||||
);
|
||||
|
||||
const longText = "A".repeat(2000);
|
||||
const result = await summarizeText(
|
||||
{
|
||||
text: longText,
|
||||
targetLength: 1500,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
completeSimple,
|
||||
getApiKeyForModel: getApiKeyForModelMock,
|
||||
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
|
||||
requireApiKey: requireApiKeyMock,
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
},
|
||||
);
|
||||
const result = await runSummarizeText({
|
||||
text: longText,
|
||||
targetLength: 1500,
|
||||
});
|
||||
|
||||
expect(result.summary).toBe(mockSummary);
|
||||
expect(result.inputLength).toBe(2000);
|
||||
@@ -483,23 +516,7 @@ describe("tts", () => {
|
||||
});
|
||||
|
||||
it("calls the summary model with the expected parameters", async () => {
|
||||
const baseConfig = resolveTtsConfig(baseCfg);
|
||||
await summarizeText(
|
||||
{
|
||||
text: "Long text to summarize",
|
||||
targetLength: 500,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
completeSimple,
|
||||
getApiKeyForModel: getApiKeyForModelMock,
|
||||
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
|
||||
requireApiKey: requireApiKeyMock,
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
},
|
||||
);
|
||||
await runSummarizeText();
|
||||
|
||||
const callArgs = vi.mocked(completeSimple).mock.calls[0];
|
||||
expect(callArgs?.[1]?.messages?.[0]?.role).toBe("user");
|
||||
@@ -513,29 +530,12 @@ describe("tts", () => {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
messages: { tts: { summaryModel: "openai/gpt-4.1-mini" } },
|
||||
};
|
||||
const config = resolveTtsConfig(cfg);
|
||||
await summarizeText(
|
||||
{
|
||||
text: "Long text to summarize",
|
||||
targetLength: 500,
|
||||
cfg,
|
||||
config,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
completeSimple,
|
||||
getApiKeyForModel: getApiKeyForModelMock,
|
||||
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
|
||||
requireApiKey: requireApiKeyMock,
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
},
|
||||
);
|
||||
await runSummarizeText({ cfg });
|
||||
|
||||
expect(resolveModelAsyncMock).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg);
|
||||
});
|
||||
|
||||
it("keeps the Ollama api for direct summarization", async () => {
|
||||
const baseConfig = resolveTtsConfig(baseCfg);
|
||||
vi.mocked(resolveModelAsyncMock).mockResolvedValue({
|
||||
...createResolvedModel("ollama", "qwen3:8b", "ollama"),
|
||||
model: {
|
||||
@@ -544,22 +544,7 @@ describe("tts", () => {
|
||||
},
|
||||
} as never);
|
||||
|
||||
await summarizeText(
|
||||
{
|
||||
text: "Long text to summarize",
|
||||
targetLength: 500,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
completeSimple,
|
||||
getApiKeyForModel: getApiKeyForModelMock,
|
||||
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
|
||||
requireApiKey: requireApiKeyMock,
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
},
|
||||
);
|
||||
await runSummarizeText();
|
||||
|
||||
expect(vi.mocked(completeSimple).mock.calls[0]?.[0]?.api).toBe("ollama");
|
||||
expect(ensureCustomApiRegisteredMock).not.toHaveBeenCalled();
|
||||
@@ -571,23 +556,7 @@ describe("tts", () => {
|
||||
{ targetLength: 10000, shouldThrow: false },
|
||||
{ targetLength: 10001, shouldThrow: true },
|
||||
] as const)("validates targetLength bounds: $targetLength", async (testCase) => {
|
||||
const baseConfig = resolveTtsConfig(baseCfg);
|
||||
const call = summarizeText(
|
||||
{
|
||||
text: "text",
|
||||
targetLength: testCase.targetLength,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
completeSimple,
|
||||
getApiKeyForModel: getApiKeyForModelMock,
|
||||
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
|
||||
requireApiKey: requireApiKeyMock,
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
},
|
||||
);
|
||||
const call = runSummarizeText({ text: "text", targetLength: testCase.targetLength });
|
||||
if (testCase.shouldThrow) {
|
||||
await expect(call, String(testCase.targetLength)).rejects.toThrow(
|
||||
`Invalid targetLength: ${testCase.targetLength}`,
|
||||
@@ -604,27 +573,10 @@ describe("tts", () => {
|
||||
message: mockAssistantMessage([{ type: "text", text: " " }]),
|
||||
},
|
||||
] as const)("throws when summary output is missing or empty: $name", async (testCase) => {
|
||||
const baseConfig = resolveTtsConfig(baseCfg);
|
||||
vi.mocked(completeSimple).mockResolvedValue(testCase.message);
|
||||
await expect(
|
||||
summarizeText(
|
||||
{
|
||||
text: "text",
|
||||
targetLength: 500,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
{
|
||||
completeSimple,
|
||||
getApiKeyForModel: getApiKeyForModelMock,
|
||||
prepareModelForSimpleCompletion: prepareModelForSimpleCompletionMock,
|
||||
requireApiKey: requireApiKeyMock,
|
||||
resolveModelAsync: resolveModelAsyncMock,
|
||||
},
|
||||
),
|
||||
testCase.name,
|
||||
).rejects.toThrow("No summary returned");
|
||||
await expect(runSummarizeText({ text: "text" }), testCase.name).rejects.toThrow(
|
||||
"No summary returned",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -765,27 +717,11 @@ describe("tts", () => {
|
||||
});
|
||||
|
||||
describe("textToSpeechTelephony – openai instructions", () => {
|
||||
const withMockedTelephonyFetch = async (
|
||||
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<void>,
|
||||
) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(2),
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
try {
|
||||
await run(fetchMock);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
};
|
||||
|
||||
async function expectTelephonyInstructions(
|
||||
model: "tts-1" | "gpt-4o-mini-tts",
|
||||
expectedInstructions: string | undefined,
|
||||
) {
|
||||
await withMockedTelephonyFetch(async (fetchMock) => {
|
||||
await withMockedSpeechFetch(async (fetchMock) => {
|
||||
const result = await tts.textToSpeechTelephony({
|
||||
text: "Hello there, friendly caller.",
|
||||
cfg: createOpenAiTelephonyCfg(model),
|
||||
@@ -797,7 +733,7 @@ describe("tts", () => {
|
||||
expect(typeof init.body).toBe("string");
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
expect(body.instructions).toBe(expectedInstructions);
|
||||
});
|
||||
}, 2);
|
||||
}
|
||||
|
||||
it.each([
|
||||
@@ -832,16 +768,9 @@ describe("tts", () => {
|
||||
) => {
|
||||
const prevPrefs = process.env.OPENCLAW_TTS_PREFS;
|
||||
process.env.OPENCLAW_TTS_PREFS = `/tmp/tts-test-${Date.now()}.json`;
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(1),
|
||||
}));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
try {
|
||||
await run(fetchMock);
|
||||
await withMockedSpeechFetch(run, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.env.OPENCLAW_TTS_PREFS = prevPrefs;
|
||||
}
|
||||
};
|
||||
@@ -854,6 +783,29 @@ describe("tts", () => {
|
||||
},
|
||||
};
|
||||
|
||||
async function expectAutoTtsOutcome(params: {
|
||||
cfg: OpenClawConfig;
|
||||
payload: { text: string };
|
||||
inboundAudio?: boolean;
|
||||
expectedFetchCalls: number;
|
||||
expectSamePayload: boolean;
|
||||
}) {
|
||||
await withMockedAutoTtsFetch(async (fetchMock) => {
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload: params.payload,
|
||||
cfg: params.cfg,
|
||||
kind: "final",
|
||||
...(params.inboundAudio !== undefined ? { inboundAudio: params.inboundAudio } : {}),
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(params.expectedFetchCalls);
|
||||
if (params.expectSamePayload) {
|
||||
expect(result).toBe(params.payload);
|
||||
} else {
|
||||
expect(result.mediaUrl).toBeDefined();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "inbound gating blocks non-audio",
|
||||
@@ -879,19 +831,12 @@ describe("tts", () => {
|
||||
] as const)(
|
||||
"applies inbound auto-TTS gating by audio status and cleaned text length: $name",
|
||||
async (testCase) => {
|
||||
await withMockedAutoTtsFetch(async (fetchMock) => {
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload: testCase.payload,
|
||||
cfg: baseCfg,
|
||||
kind: "final",
|
||||
inboundAudio: testCase.inboundAudio,
|
||||
});
|
||||
expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls);
|
||||
if (testCase.expectSamePayload) {
|
||||
expect(result, testCase.name).toBe(testCase.payload);
|
||||
} else {
|
||||
expect(result.mediaUrl, testCase.name).toBeDefined();
|
||||
}
|
||||
await expectAutoTtsOutcome({
|
||||
cfg: baseCfg,
|
||||
payload: testCase.payload,
|
||||
inboundAudio: testCase.inboundAudio,
|
||||
expectedFetchCalls: testCase.expectedFetchCalls,
|
||||
expectSamePayload: testCase.expectSamePayload,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -910,19 +855,11 @@ describe("tts", () => {
|
||||
expectSamePayload: false,
|
||||
},
|
||||
] as const)("respects tagged-mode auto-TTS gating: $name", async (testCase) => {
|
||||
await withMockedAutoTtsFetch(async (fetchMock) => {
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload: testCase.payload,
|
||||
cfg: taggedCfg,
|
||||
kind: "final",
|
||||
});
|
||||
|
||||
expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls);
|
||||
if (testCase.expectSamePayload) {
|
||||
expect(result, testCase.name).toBe(testCase.payload);
|
||||
} else {
|
||||
expect(result.mediaUrl, testCase.name).toBeDefined();
|
||||
}
|
||||
await expectAutoTtsOutcome({
|
||||
cfg: taggedCfg,
|
||||
payload: testCase.payload,
|
||||
expectedFetchCalls: testCase.expectedFetchCalls,
|
||||
expectSamePayload: testCase.expectSamePayload,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,9 +101,17 @@ const TEST_PROVIDER_IDS = TEST_PROVIDERS.map((provider) => provider.id).toSorted
|
||||
left.localeCompare(right),
|
||||
);
|
||||
|
||||
function sortedValues(values: readonly string[]) {
|
||||
return [...values].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function expectUniqueValues(values: readonly string[]) {
|
||||
expect(values).toEqual([...new Set(values)]);
|
||||
}
|
||||
|
||||
function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
|
||||
return providers
|
||||
.flatMap((provider) => {
|
||||
return sortedValues(
|
||||
providers.flatMap((provider) => {
|
||||
const methodSetups = provider.auth.filter((method) => method.wizard);
|
||||
if (methodSetups.length > 0) {
|
||||
return methodSetups.map(
|
||||
@@ -130,13 +138,13 @@ function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
|
||||
}
|
||||
|
||||
return provider.auth.map((method) => buildProviderPluginMethodChoice(provider.id, method.id));
|
||||
})
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
|
||||
return providers
|
||||
.flatMap((provider) => {
|
||||
return sortedValues(
|
||||
providers.flatMap((provider) => {
|
||||
const modelPicker = provider.wizard?.modelPicker;
|
||||
if (!modelPicker) {
|
||||
return [];
|
||||
@@ -149,8 +157,18 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
|
||||
return [provider.id];
|
||||
}
|
||||
return [buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default")];
|
||||
})
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectAllChoicesResolve(
|
||||
values: readonly string[],
|
||||
resolver: (choice: string) => ReturnType<typeof resolveProviderPluginChoice>,
|
||||
) {
|
||||
expect(
|
||||
values.every((value) => Boolean(resolver(value))),
|
||||
values.join(", "),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
describe("provider wizard contract", () => {
|
||||
@@ -173,45 +191,38 @@ describe("provider wizard contract", () => {
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(
|
||||
options.map((option) => option.value).toSorted((left, right) => left.localeCompare(right)),
|
||||
).toEqual(resolveExpectedWizardChoiceValues(TEST_PROVIDERS));
|
||||
expect(options.map((option) => option.value)).toEqual([
|
||||
...new Set(options.map((option) => option.value)),
|
||||
]);
|
||||
expect(sortedValues(options.map((option) => option.value))).toEqual(
|
||||
resolveExpectedWizardChoiceValues(TEST_PROVIDERS),
|
||||
);
|
||||
expectUniqueValues(options.map((option) => option.value));
|
||||
});
|
||||
|
||||
it("round-trips every shared wizard choice back to its provider and auth method", () => {
|
||||
const options = resolveProviderWizardOptions({ config: {}, env: process.env });
|
||||
|
||||
expect(
|
||||
options.every((option) => {
|
||||
const resolved = resolveProviderPluginChoice({
|
||||
expectAllChoicesResolve(
|
||||
options.map((option) => option.value),
|
||||
(choice) =>
|
||||
resolveProviderPluginChoice({
|
||||
providers: TEST_PROVIDERS,
|
||||
choice: option.value,
|
||||
});
|
||||
return Boolean(resolved?.provider.id && resolved?.method.id);
|
||||
}),
|
||||
options.map((option) => option.value).join(", "),
|
||||
).toBe(true);
|
||||
choice,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes every model-picker entry through the shared wizard layer", () => {
|
||||
const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env });
|
||||
|
||||
expect(
|
||||
entries.map((entry) => entry.value).toSorted((left, right) => left.localeCompare(right)),
|
||||
).toEqual(resolveExpectedModelPickerValues(TEST_PROVIDERS));
|
||||
expect(
|
||||
entries.every((entry) =>
|
||||
Boolean(
|
||||
resolveProviderPluginChoice({
|
||||
providers: TEST_PROVIDERS,
|
||||
choice: entry.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
entries.map((entry) => entry.value).join(", "),
|
||||
).toBe(true);
|
||||
expect(sortedValues(entries.map((entry) => entry.value))).toEqual(
|
||||
resolveExpectedModelPickerValues(TEST_PROVIDERS),
|
||||
);
|
||||
expectAllChoicesResolve(
|
||||
entries.map((entry) => entry.value),
|
||||
(choice) =>
|
||||
resolveProviderPluginChoice({
|
||||
providers: TEST_PROVIDERS,
|
||||
choice,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user