test: dedupe plugin provider helper suites

This commit is contained in:
Peter Steinberger 2026-03-28 04:26:00 +00:00
parent 7e921050e3
commit 9155f3914a
10 changed files with 259 additions and 105 deletions

View File

@ -105,6 +105,46 @@ function setBundledCapabilityFixture(contractKey: string) {
});
}
function setActiveSpeechCapabilityRegistry(providerId: string) {
const active = createEmptyPluginRegistry();
active.speechProviders.push({
pluginId: providerId,
pluginName: "OpenAI",
source: "test",
provider: {
id: providerId,
label: "OpenAI",
isConfigured: () => true,
synthesize: async () => ({
audioBuffer: Buffer.from("x"),
outputFormat: "mp3",
voiceCompatible: false,
fileExtension: ".mp3",
}),
},
});
setActivePluginRegistry(active);
}
function expectCompatChainApplied(params: {
key: "speechProviders" | "mediaUnderstandingProviders" | "imageGenerationProviders";
contractKey: string;
cfg: OpenClawConfig;
allowlistCompat: { plugins: { allow: string[] } };
enablementCompat: {
plugins: {
allow: string[];
entries: { openai: { enabled: boolean } };
};
};
}) {
setBundledCapabilityFixture(params.contractKey);
mocks.withBundledPluginAllowlistCompat.mockReturnValue(params.allowlistCompat);
mocks.withBundledPluginEnablementCompat.mockReturnValue(params.enablementCompat);
mocks.withBundledPluginVitestCompat.mockReturnValue(params.enablementCompat);
resolvePluginCapabilityProviders({ key: params.key, cfg: params.cfg });
expectBundledCompatLoadPath(params);
}
describe("resolvePluginCapabilityProviders", () => {
beforeEach(async () => {
vi.resetModules();
@ -154,14 +194,9 @@ describe("resolvePluginCapabilityProviders", () => {
["imageGenerationProviders", "imageGenerationProviders"],
] as const)("applies bundled compat before fallback loading for %s", (key, contractKey) => {
const { cfg, allowlistCompat, enablementCompat } = createCompatChainConfig();
setBundledCapabilityFixture(contractKey);
mocks.withBundledPluginAllowlistCompat.mockReturnValue(allowlistCompat);
mocks.withBundledPluginEnablementCompat.mockReturnValue(enablementCompat);
mocks.withBundledPluginVitestCompat.mockReturnValue(enablementCompat);
resolvePluginCapabilityProviders({ key, cfg });
expectBundledCompatLoadPath({
expectCompatChainApplied({
key,
contractKey,
cfg,
allowlistCompat,
enablementCompat,

View File

@ -37,6 +37,22 @@ function createOwnedAdapterEntry(id: string) {
};
}
function expectRegisteredProviderState(params: {
entry: {
adapter: MemoryEmbeddingProviderAdapter;
ownerPluginId?: string;
};
expectedList?: Array<{
adapter: MemoryEmbeddingProviderAdapter;
ownerPluginId?: string;
}>;
}) {
expectRegisteredProviderEntry(params.entry.adapter.id, params.entry);
if (params.expectedList) {
expect(listRegisteredMemoryEmbeddingProviders()).toEqual(params.expectedList);
}
}
function expectMemoryEmbeddingProviderIds(expectedIds: readonly string[]) {
expect(listMemoryEmbeddingProviders().map((adapter) => adapter.id)).toEqual([...expectedIds]);
}
@ -81,14 +97,11 @@ describe("memory embedding provider registry", () => {
expectList: false,
},
] as const)("$name", ({ entry, setup, expectList }) => {
const expectedEntry = entry;
setup(entry);
expectRegisteredProviderEntry(entry.adapter.id, expectedEntry);
if (expectList) {
expect(listRegisteredMemoryEmbeddingProviders()).toEqual([expectedEntry]);
}
expectRegisteredProviderState({
entry,
...(expectList ? { expectedList: [entry] } : {}),
});
});
it("clears the registry", () => {

View File

@ -20,6 +20,10 @@ function createManifestPlugin(id: string, providerAuthChoices: Array<Record<stri
};
}
function createProviderAuthChoice(overrides: Record<string, unknown>) {
return overrides;
}
function setManifestPlugins(plugins: Array<Record<string, unknown>>) {
loadPluginManifestRegistry.mockReturnValue({
plugins,
@ -36,21 +40,26 @@ function expectDeprecatedAuthChoice(choiceIds: string[], expectedChoiceId?: stri
}
}
function setSingleManifestProviderAuthChoices(
pluginId: string,
providerAuthChoices: Array<Record<string, unknown>>,
) {
setManifestPlugins([createManifestPlugin(pluginId, providerAuthChoices)]);
}
describe("provider auth choice manifest helpers", () => {
it("flattens manifest auth choices", () => {
setManifestPlugins([
createManifestPlugin("openai", [
{
provider: "openai",
method: "api-key",
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
onboardingScopes: ["text-inference"],
optionKey: "openaiApiKey",
cliFlag: "--openai-api-key",
cliOption: "--openai-api-key <key>",
},
]),
setSingleManifestProviderAuthChoices("openai", [
createProviderAuthChoice({
provider: "openai",
method: "api-key",
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
onboardingScopes: ["text-inference"],
optionKey: "openaiApiKey",
cliFlag: "--openai-api-key",
cliOption: "--openai-api-key <key>",
}),
]);
expect(resolveManifestProviderAuthChoices()).toEqual([
@ -74,7 +83,7 @@ describe("provider auth choice manifest helpers", () => {
name: "deduplicates flag metadata by option key + flag",
plugins: [
createManifestPlugin("moonshot", [
{
createProviderAuthChoice({
provider: "moonshot",
method: "api-key",
choiceId: "moonshot-api-key",
@ -83,8 +92,8 @@ describe("provider auth choice manifest helpers", () => {
cliFlag: "--moonshot-api-key",
cliOption: "--moonshot-api-key <key>",
cliDescription: "Moonshot API key",
},
{
}),
createProviderAuthChoice({
provider: "moonshot",
method: "api-key-cn",
choiceId: "moonshot-api-key-cn",
@ -93,7 +102,7 @@ describe("provider auth choice manifest helpers", () => {
cliFlag: "--moonshot-api-key",
cliOption: "--moonshot-api-key <key>",
cliDescription: "Moonshot API key",
},
}),
]),
],
run: () =>
@ -111,12 +120,12 @@ describe("provider auth choice manifest helpers", () => {
name: "resolves deprecated auth-choice aliases through manifest metadata",
plugins: [
createManifestPlugin("minimax", [
{
createProviderAuthChoice({
provider: "minimax",
method: "api-global",
choiceId: "minimax-global-api",
deprecatedChoiceIds: ["minimax", "minimax-api"],
},
}),
]),
],
run: () => {

View File

@ -61,6 +61,19 @@ function createCatalogRuntimeContext() {
};
}
function expectNormalizedDiscoveryResult(params: {
provider: ProviderPlugin;
result: Parameters<typeof normalizePluginDiscoveryResult>[0]["result"];
expected: Record<string, unknown>;
}) {
expect(
normalizePluginDiscoveryResult({
provider: params.provider,
result: params.result,
}),
).toEqual(params.expected);
}
describe("groupPluginDiscoveryProvidersByOrder", () => {
it.each([
{
@ -132,8 +145,7 @@ describe("normalizePluginDiscoveryResult", () => {
},
},
] as const)("$name", ({ provider, result, expected }) => {
const normalized = normalizePluginDiscoveryResult({ provider, result });
expect(normalized).toEqual(expected);
expectNormalizedDiscoveryResult({ provider, result, expected });
});
});

View File

@ -45,6 +45,12 @@ function expectProviderModels(
expect(providers?.[providerId]).toMatchObject(expected);
}
function expectDefaultPrimaryModel(cfg: OpenClawConfig, modelRef: string) {
expect(cfg.agents?.defaults?.model).toEqual({
primary: modelRef,
});
}
function createDemoProviderParams(params?: {
providerId?: string;
baseUrl?: string;
@ -135,9 +141,7 @@ describe("provider onboarding preset appliers", () => {
{ id: "b", name: "Model B" },
],
});
expect(cfg.agents?.defaults?.model).toEqual({
primary: "demo/a",
});
expectDefaultPrimaryModel(cfg, "demo/a");
return;
}

View File

@ -67,6 +67,20 @@ function expectNormalizedProviderFixture(params: {
return result;
}
function expectProviderNormalizationResult(params: {
provider: ProviderPlugin;
expectedProvider?: Record<string, unknown>;
expectedDiagnostics?: ReadonlyArray<{ level: PluginDiagnostic["level"]; message: string }>;
expectedDiagnosticText?: readonly string[];
assert?: (
provider: ReturnType<typeof normalizeRegisteredProvider>,
diagnostics: PluginDiagnostic[],
) => void;
}) {
const { diagnostics, provider } = expectNormalizedProviderFixture(params);
params.assert?.(provider, diagnostics);
}
describe("normalizeRegisteredProvider", () => {
it.each([
{
@ -187,16 +201,12 @@ describe("normalizeRegisteredProvider", () => {
] as const)(
"$name",
({ provider: inputProvider, expectedProvider, expectedDiagnostics, assert }) => {
const { diagnostics, provider } = expectNormalizedProviderFixture({
expectProviderNormalizationResult({
provider: inputProvider,
...(expectedProvider ? { expectedProvider } : {}),
...(expectedDiagnostics ? { expectedDiagnostics } : {}),
...(assert ? { assert } : {}),
});
if (assert) {
assert(provider, diagnostics);
return;
}
},
);

View File

@ -94,6 +94,32 @@ function createWizardRuntimeParams(params?: {
};
}
function expectWizardResolutionCount(params: {
provider: ProviderPlugin;
config?: object;
env?: NodeJS.ProcessEnv;
expectedCount: number;
}) {
setResolvedProviders(params.provider);
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
resolveProviderWizardOptions(
createWizardRuntimeParams({
config: params.config,
env: params.env,
}),
);
expectProviderResolutionCall({
config: params.config,
env: params.env,
count: params.expectedCount,
});
}
function expectProviderResolutionCall(params?: {
config?: object;
env?: NodeJS.ProcessEnv;
@ -112,21 +138,6 @@ function setResolvedProviders(...providers: ProviderPlugin[]) {
resolvePluginProviders.mockReturnValue(providers);
}
function resolveWizardOptionsTwice(params: {
config?: object;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
}) {
const runtimeParams = createWizardRuntimeParams(params);
resolveProviderWizardOptions(runtimeParams);
resolveProviderWizardOptions(runtimeParams);
}
function expectWizardProviderCacheMiss(params: { config?: object; env: NodeJS.ProcessEnv }) {
resolveWizardOptionsTwice(params);
expectProviderResolutionCall({ ...params, count: 2 });
}
function expectSingleWizardChoice(params: {
provider: ProviderPlugin;
choice: string;
@ -363,11 +374,12 @@ describe("provider wizard boundaries", () => {
}),
},
] as const)("$name", ({ env }) => {
const provider = createSglangSetupProvider();
const config = createSglangConfig();
setResolvedProviders(provider);
expectWizardProviderCacheMiss({ config, env });
expectWizardResolutionCount({
provider: createSglangSetupProvider(),
config: createSglangConfig(),
env,
expectedCount: 2,
});
});
it("expires provider-wizard memoization after the shortest plugin cache ttl", () => {

View File

@ -31,6 +31,17 @@ describe("gateway request scope", () => {
expect(runtimeScope.getPluginRuntimeGatewayRequestScope()).toEqual(expected);
}
async function expectGatewayScopeWithPluginId(pluginId: string) {
await withTestGatewayScope(async (runtimeScope) => {
await runtimeScope.withPluginRuntimePluginIdScope(pluginId, async () => {
expectGatewayScope(runtimeScope, {
...TEST_SCOPE,
pluginId,
});
});
});
}
it("reuses AsyncLocalStorage across reloaded module instances", async () => {
const first = await importGatewayRequestScopeModule();
@ -42,13 +53,6 @@ describe("gateway request scope", () => {
});
it("attaches plugin id to the active scope", async () => {
await withTestGatewayScope(async (runtimeScope) => {
await runtimeScope.withPluginRuntimePluginIdScope("voice-call", async () => {
expectGatewayScope(runtimeScope, {
...TEST_SCOPE,
pluginId: "voice-call",
});
});
});
await expectGatewayScopeWithPluginId("voice-call");
});
});

View File

@ -56,6 +56,20 @@ function expectFunctionKeys(value: Record<string, unknown>, keys: readonly strin
});
}
function expectRunCommandOutcome(params: {
runtime: ReturnType<typeof createPluginRuntime>;
expected: "resolve" | "reject";
commandResult: ReturnType<typeof createCommandResult>;
}) {
const command = params.runtime.system.runCommandWithTimeout(["echo", "hello"], {
timeoutMs: 1000,
});
if (params.expected === "resolve") {
return expect(command).resolves.toEqual(params.commandResult);
}
return expect(command).rejects.toThrow("boom");
}
describe("plugin runtime command execution", () => {
beforeEach(() => {
vi.restoreAllMocks();
@ -83,12 +97,7 @@ describe("plugin runtime command execution", () => {
}
const runtime = createPluginRuntime();
const command = runtime.system.runCommandWithTimeout(["echo", "hello"], { timeoutMs: 1000 });
if (expected === "resolve") {
await expect(command).resolves.toEqual(commandResult);
} else {
await expect(command).rejects.toThrow("boom");
}
await expectRunCommandOutcome({ runtime, expected, commandResult });
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(["echo", "hello"], { timeoutMs: 1000 });
});

View File

@ -202,6 +202,47 @@ function expectSnapshotMemoization(params: {
expectLoaderCallCount(params.expectedLoaderCalls);
}
function expectAutoEnabledWebSearchLoad(params: {
rawConfig: { plugins?: Record<string, unknown> };
expectedAllow: readonly string[];
}) {
expect(applyPluginAutoEnableSpy).toHaveBeenCalledWith({
config: params.rawConfig,
env: createWebSearchEnv(),
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining([...params.expectedAllow]),
}),
}),
}),
);
}
function expectSnapshotLoaderCalls(params: {
config: { plugins?: Record<string, unknown> };
env: NodeJS.ProcessEnv;
mutate: () => void;
expectedLoaderCalls: number;
}) {
resolvePluginWebSearchProviders(
createSnapshotParams({
config: params.config,
env: params.env,
}),
);
params.mutate();
resolvePluginWebSearchProviders(
createSnapshotParams({
config: params.config,
env: params.env,
}),
);
expectLoaderCallCount(params.expectedLoaderCalls);
}
describe("resolvePluginWebSearchProviders", () => {
beforeAll(async () => {
({ createEmptyPluginRegistry } = await import("./registry.js"));
@ -272,19 +313,10 @@ describe("resolvePluginWebSearchProviders", () => {
resolvePluginWebSearchProviders(createSnapshotParams({ config: rawConfig }));
expect(applyPluginAutoEnableSpy).toHaveBeenCalledWith({
config: rawConfig,
env: createWebSearchEnv(),
expectAutoEnabledWebSearchLoad({
rawConfig,
expectedAllow: ["brave", "perplexity"],
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining(["brave", "perplexity"]),
}),
}),
}),
);
});
it("scopes plugin loading to manifest-declared web-search candidates", () => {
@ -301,16 +333,29 @@ describe("resolvePluginWebSearchProviders", () => {
});
});
it("invalidates the snapshot cache when config or env contents change in place", () => {
it.each([
{
name: "invalidates the snapshot cache when config contents change in place",
mutate: (config: { plugins?: Record<string, unknown> }, _env: NodeJS.ProcessEnv) => {
config.plugins = { allow: ["perplexity"] };
},
},
{
name: "invalidates the snapshot cache when env contents change in place",
mutate: (_config: { plugins?: Record<string, unknown> }, env: NodeJS.ProcessEnv) => {
env.OPENCLAW_HOME = "/tmp/openclaw-home-b";
},
},
] as const)("$name", ({ mutate }) => {
const config = createBraveAllowConfig();
const env = createWebSearchEnv({ OPENCLAW_HOME: "/tmp/openclaw-home-a" });
resolvePluginWebSearchProviders(createSnapshotParams({ config, env }));
config.plugins.allow = ["perplexity"];
env.OPENCLAW_HOME = "/tmp/openclaw-home-b";
resolvePluginWebSearchProviders(createSnapshotParams({ config, env }));
expectLoaderCallCount(2);
expectSnapshotLoaderCalls({
config,
env,
mutate: () => mutate(config, env),
expectedLoaderCalls: 2,
});
});
it.each([
@ -380,13 +425,14 @@ describe("resolvePluginWebSearchProviders", () => {
OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000",
});
resolvePluginWebSearchProviders(createSnapshotParams({ config, env }));
env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5";
resolvePluginWebSearchProviders(createSnapshotParams({ config, env }));
expectLoaderCallCount(2);
expectSnapshotLoaderCalls({
config,
env,
mutate: () => {
env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5";
},
expectedLoaderCalls: 2,
});
});
it("prefers the active plugin registry for runtime resolution", () => {