test: dedupe plugin core utility suites

This commit is contained in:
Peter Steinberger
2026-03-28 01:37:20 +00:00
parent 2accc0391a
commit fad42b19ee
8 changed files with 320 additions and 322 deletions

View File

@@ -6,50 +6,16 @@ import {
} from "./config-state.js";
describe("normalizePluginsConfig", () => {
it("uses default memory slot when not specified", () => {
const result = normalizePluginsConfig({});
expect(result.slots.memory).toBe("memory-core");
});
it("respects explicit memory slot value", () => {
const result = normalizePluginsConfig({
slots: { memory: "custom-memory" },
});
expect(result.slots.memory).toBe("custom-memory");
});
it("disables memory slot when set to 'none' (case insensitive)", () => {
expect(
normalizePluginsConfig({
slots: { memory: "none" },
}).slots.memory,
).toBeNull();
expect(
normalizePluginsConfig({
slots: { memory: "None" },
}).slots.memory,
).toBeNull();
});
it("trims whitespace from memory slot value", () => {
const result = normalizePluginsConfig({
slots: { memory: " custom-memory " },
});
expect(result.slots.memory).toBe("custom-memory");
});
it("uses default when memory slot is empty string", () => {
const result = normalizePluginsConfig({
slots: { memory: "" },
});
expect(result.slots.memory).toBe("memory-core");
});
it("uses default when memory slot is whitespace only", () => {
const result = normalizePluginsConfig({
slots: { memory: " " },
});
expect(result.slots.memory).toBe("memory-core");
it.each([
[{}, "memory-core"],
[{ slots: { memory: "custom-memory" } }, "custom-memory"],
[{ slots: { memory: "none" } }, null],
[{ slots: { memory: "None" } }, null],
[{ slots: { memory: " custom-memory " } }, "custom-memory"],
[{ slots: { memory: "" } }, "memory-core"],
[{ slots: { memory: " " } }, "memory-core"],
] as const)("normalizes memory slot for %o", (config, expected) => {
expect(normalizePluginsConfig(config).slots.memory).toBe(expected);
});
it("normalizes plugin hook policy flags", () => {
@@ -172,36 +138,42 @@ describe("resolveEffectiveEnableState", () => {
});
}
it("enables bundled channels when channels.<id>.enabled=true", () => {
const state = resolveBundledTelegramState({
enabled: true,
});
expect(state).toEqual({ enabled: true });
});
it("keeps explicit plugin-level disable authoritative", () => {
const state = resolveBundledTelegramState({
enabled: true,
entries: {
telegram: {
enabled: false,
it.each([
[{ enabled: true }, { enabled: true }],
[
{
enabled: true,
entries: {
telegram: {
enabled: false,
},
},
},
});
expect(state).toEqual({ enabled: false, reason: "disabled in config" });
{ enabled: false, reason: "disabled in config" },
],
] as const)("resolves bundled telegram state for %o", (config, expected) => {
expect(resolveBundledTelegramState(config)).toEqual(expected);
});
});
describe("resolveEnableState", () => {
it("enables bundled plugins only when manifest metadata marks them enabled by default", () => {
expect(resolveEnableState("openai", "bundled", normalizePluginsConfig({}))).toEqual({
enabled: false,
reason: "bundled (disabled by default)",
});
expect(resolveEnableState("openai", "bundled", normalizePluginsConfig({}), true)).toEqual({
enabled: true,
});
});
it.each([
[
"openai",
"bundled",
normalizePluginsConfig({}),
undefined,
{ enabled: false, reason: "bundled (disabled by default)" },
],
["openai", "bundled", normalizePluginsConfig({}), true, { enabled: true }],
["google", "bundled", normalizePluginsConfig({}), true, { enabled: true }],
["profile-aware", "bundled", normalizePluginsConfig({}), true, { enabled: true }],
] as const)(
"resolves %s enable state for origin=%s manifestEnabledByDefault=%s",
(id, origin, config, manifestEnabledByDefault, expected) => {
expect(resolveEnableState(id, origin, config, manifestEnabledByDefault)).toEqual(expected);
},
);
it("keeps the selected memory slot plugin enabled even when omitted from plugins.allow", () => {
const state = resolveEnableState(
@@ -232,29 +204,21 @@ describe("resolveEnableState", () => {
expect(state).toEqual({ enabled: false, reason: "disabled in config" });
});
it("disables workspace plugins by default when they are only auto-discovered from the workspace", () => {
const state = resolveEnableState("workspace-helper", "workspace", normalizePluginsConfig({}));
expect(state).toEqual({
enabled: false,
reason: "workspace plugin (disabled by default)",
});
});
it("allows workspace plugins when explicitly listed in plugins.allow", () => {
const state = resolveEnableState(
"workspace-helper",
"workspace",
it.each([
[
normalizePluginsConfig({}),
{
enabled: false,
reason: "workspace plugin (disabled by default)",
},
],
[
normalizePluginsConfig({
allow: ["workspace-helper"],
}),
);
expect(state).toEqual({ enabled: true });
});
it("allows workspace plugins when explicitly enabled in plugin entries", () => {
const state = resolveEnableState(
"workspace-helper",
"workspace",
{ enabled: true },
],
[
normalizePluginsConfig({
entries: {
"workspace-helper": {
@@ -262,8 +226,10 @@ describe("resolveEnableState", () => {
},
},
}),
);
expect(state).toEqual({ enabled: true });
{ enabled: true },
],
] as const)("resolves workspace-helper enable state for %o", (config, expected) => {
expect(resolveEnableState("workspace-helper", "workspace", config)).toEqual(expected);
});
it("does not let the default memory slot auto-enable an untrusted workspace plugin", () => {
@@ -279,14 +245,4 @@ describe("resolveEnableState", () => {
reason: "workspace plugin (disabled by default)",
});
});
it("keeps bundled plugins enabled when manifest metadata marks them enabled by default", () => {
const state = resolveEnableState("google", "bundled", normalizePluginsConfig({}), true);
expect(state).toEqual({ enabled: true });
});
it("allows bundled plugins to opt into default enablement from manifest metadata", () => {
const state = resolveEnableState("profile-aware", "bundled", normalizePluginsConfig({}), true);
expect(state).toEqual({ enabled: true });
});
});

View File

@@ -111,16 +111,18 @@ describe("provider auth-choice contract", () => {
},
];
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 Promise.all(
pluginFallbackScenarios.map(async (provider) => {
resolvePluginProvidersMock.mockClear();
resolvePluginProvidersMock.mockReturnValue([provider]);
await expect(
resolvePreferredProviderForAuthChoice({
choice: buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default"),
}),
).resolves.toBe(provider.id);
expect(resolvePluginProvidersMock).toHaveBeenCalled();
}),
);
resolvePluginProvidersMock.mockClear();
await expect(resolvePreferredProviderForAuthChoice({ choice: "unknown" })).resolves.toBe(

View File

@@ -15,16 +15,27 @@ function resolveBundledManifestProviderPluginIds() {
);
}
function expectPluginAllowlistContains(
allow: string[] | undefined,
pluginIds: string[],
expectedExtraEntry?: string,
) {
expect(allow).toEqual(expect.arrayContaining(pluginIds));
if (expectedExtraEntry) {
expect(allow).toContain(expectedExtraEntry);
}
}
const demoAllowEntry = "demo-allowed";
describe("plugin loader contract", () => {
let providerPluginIds: string[];
let manifestProviderPluginIds: string[];
let compatPluginIds: string[];
let providerPluginIds: string[] = [];
let manifestProviderPluginIds: string[] = [];
let compatPluginIds: string[] = [];
let compatConfig: ReturnType<typeof withBundledPluginAllowlistCompat>;
let vitestCompatConfig: ReturnType<typeof providerTesting.withBundledProviderVitestCompat>;
let webSearchPluginIds: string[];
let bundledWebSearchPluginIds: string[];
let webSearchPluginIds: string[] = [];
let bundledWebSearchPluginIds: string[] = [];
let webSearchAllowlistCompatConfig: ReturnType<typeof withBundledPluginAllowlistCompat>;
beforeAll(() => {
@@ -72,24 +83,32 @@ describe("plugin loader contract", () => {
expect(providerPluginIds).toEqual(manifestProviderPluginIds);
expect(uniqueSortedStrings(compatPluginIds)).toEqual(manifestProviderPluginIds);
expect(uniqueSortedStrings(compatPluginIds)).toEqual(expect.arrayContaining(providerPluginIds));
expect(compatConfig?.plugins?.allow).toEqual(expect.arrayContaining(providerPluginIds));
expectPluginAllowlistContains(compatConfig?.plugins?.allow, providerPluginIds, demoAllowEntry);
});
it("keeps vitest bundled provider enablement wired to the provider registry", () => {
expect(providerPluginIds).toEqual(manifestProviderPluginIds);
expect(vitestCompatConfig?.plugins).toMatchObject({
enabled: true,
allow: expect.arrayContaining(providerPluginIds),
});
expect(vitestCompatConfig?.plugins?.enabled).toBe(true);
expectPluginAllowlistContains(vitestCompatConfig?.plugins?.allow, providerPluginIds);
});
it("keeps bundled web search loading scoped to the web search registry", () => {
expect(bundledWebSearchPluginIds).toEqual(webSearchPluginIds);
});
it("keeps bundled web search allowlist compatibility wired to the web search registry", () => {
expect(webSearchAllowlistCompatConfig?.plugins?.allow).toEqual(
expect.arrayContaining(webSearchPluginIds),
);
it.each([
{
name: "keeps bundled web search loading scoped to the web search registry",
actual: bundledWebSearchPluginIds,
expected: webSearchPluginIds,
},
{
name: "keeps bundled web search allowlist compatibility wired to the web search registry",
actual: webSearchAllowlistCompatConfig?.plugins?.allow,
expected: webSearchPluginIds,
extraEntry: demoAllowEntry,
},
] as const)("$name", ({ actual, expected, extraEntry }) => {
if (Array.isArray(actual) && extraEntry == null) {
expect(actual).toEqual(expected);
return;
}
expectPluginAllowlistContains(actual, expected, extraEntry);
});
});

View File

@@ -102,45 +102,36 @@ const TEST_PROVIDER_IDS = TEST_PROVIDERS.map((provider) => provider.id).toSorted
);
function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
const values: string[] = [];
for (const provider of providers) {
const methodSetups = provider.auth.filter((method) => method.wizard);
if (methodSetups.length > 0) {
values.push(
...methodSetups.map(
return providers
.flatMap((provider) => {
const methodSetups = provider.auth.filter((method) => method.wizard);
if (methodSetups.length > 0) {
return methodSetups.map(
(method) =>
method.wizard?.choiceId?.trim() ||
buildProviderPluginMethodChoice(provider.id, method.id),
),
);
continue;
}
);
}
const setup = provider.wizard?.setup;
if (!setup) {
continue;
}
const setup = provider.wizard?.setup;
if (!setup) {
return [];
}
const explicitMethodId = setup.methodId?.trim();
if (explicitMethodId && provider.auth.some((method) => method.id === explicitMethodId)) {
values.push(
setup.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, explicitMethodId),
);
continue;
}
const explicitMethodId = setup.methodId?.trim();
if (explicitMethodId && provider.auth.some((method) => method.id === explicitMethodId)) {
return [
setup.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, explicitMethodId),
];
}
if (provider.auth.length === 1) {
values.push(setup.choiceId?.trim() || provider.id);
continue;
}
if (provider.auth.length === 1) {
return [setup.choiceId?.trim() || provider.id];
}
values.push(
...provider.auth.map((method) => buildProviderPluginMethodChoice(provider.id, method.id)),
);
}
return values.toSorted((left, right) => left.localeCompare(right));
return provider.auth.map((method) => buildProviderPluginMethodChoice(provider.id, method.id));
})
.toSorted((left, right) => left.localeCompare(right));
}
function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
@@ -193,24 +184,16 @@ describe("provider wizard contract", () => {
it("round-trips every shared wizard choice back to its provider and auth method", () => {
const options = resolveProviderWizardOptions({ config: {}, env: process.env });
expect(options).toEqual(
expect.arrayContaining(
options.map((option) =>
expect.objectContaining({
value: option.value,
}),
),
),
);
for (const option of options) {
const resolved = resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice: option.value,
});
expect(resolved, option.value).not.toBeNull();
expect(resolved?.provider.id, option.value).toBeTruthy();
expect(resolved?.method.id, option.value).toBeTruthy();
}
expect(
options.every((option) => {
const resolved = resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice: option.value,
});
return Boolean(resolved?.provider.id && resolved?.method.id);
}),
options.map((option) => option.value).join(", "),
).toBe(true);
});
it("exposes every model-picker entry through the shared wizard layer", () => {
@@ -219,12 +202,16 @@ describe("provider wizard contract", () => {
expect(
entries.map((entry) => entry.value).toSorted((left, right) => left.localeCompare(right)),
).toEqual(resolveExpectedModelPickerValues(TEST_PROVIDERS));
for (const entry of entries) {
const resolved = resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice: entry.value,
});
expect(resolved, entry.value).not.toBeNull();
}
expect(
entries.every((entry) =>
Boolean(
resolveProviderPluginChoice({
providers: TEST_PROVIDERS,
choice: entry.value,
}),
),
),
entries.map((entry) => entry.value).join(", "),
).toBe(true);
});
});

View File

@@ -2,34 +2,40 @@ import { describe, expect, it } from "vitest";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
describe("buildNpmResolutionInstallFields", () => {
it("maps npm resolution metadata into install record fields", () => {
const fields = buildNpmResolutionInstallFields({
name: "@openclaw/demo",
version: "1.2.3",
resolvedSpec: "@openclaw/demo@1.2.3",
integrity: "sha512-abc",
shasum: "deadbeef",
resolvedAt: "2026-02-22T00:00:00.000Z",
});
expect(fields).toEqual({
resolvedName: "@openclaw/demo",
resolvedVersion: "1.2.3",
resolvedSpec: "@openclaw/demo@1.2.3",
integrity: "sha512-abc",
shasum: "deadbeef",
resolvedAt: "2026-02-22T00:00:00.000Z",
});
});
it("returns undefined fields when resolution is missing", () => {
expect(buildNpmResolutionInstallFields(undefined)).toEqual({
resolvedName: undefined,
resolvedVersion: undefined,
resolvedSpec: undefined,
integrity: undefined,
shasum: undefined,
resolvedAt: undefined,
});
it.each([
{
name: "maps npm resolution metadata into install record fields",
input: {
name: "@openclaw/demo",
version: "1.2.3",
resolvedSpec: "@openclaw/demo@1.2.3",
integrity: "sha512-abc",
shasum: "deadbeef",
resolvedAt: "2026-02-22T00:00:00.000Z",
},
expected: {
resolvedName: "@openclaw/demo",
resolvedVersion: "1.2.3",
resolvedSpec: "@openclaw/demo@1.2.3",
integrity: "sha512-abc",
shasum: "deadbeef",
resolvedAt: "2026-02-22T00:00:00.000Z",
},
},
{
name: "returns undefined fields when resolution is missing",
input: undefined,
expected: {
resolvedName: undefined,
resolvedVersion: undefined,
resolvedSpec: undefined,
integrity: undefined,
shasum: undefined,
resolvedAt: undefined,
},
},
] as const)("$name", ({ input, expected }) => {
expect(buildNpmResolutionInstallFields(input)).toEqual(expected);
});
});

View File

@@ -18,47 +18,57 @@ function createContext(models: ProviderRuntimeModel[]): ProviderResolveDynamicMo
}
describe("cloneFirstTemplateModel", () => {
it("clones the first matching template and applies patches", () => {
const model = cloneFirstTemplateModel({
providerId: "test-provider",
modelId: " next-model ",
templateIds: ["missing", "template-a", "template-b"],
ctx: createContext([
{
id: "template-a",
name: "Template A",
provider: "test-provider",
api: "openai-completions",
} as ProviderRuntimeModel,
]),
patch: { reasoning: true },
});
expect(model).toMatchObject({
id: "next-model",
name: "next-model",
provider: "test-provider",
api: "openai-completions",
reasoning: true,
});
});
it("returns undefined when no template exists", () => {
const model = cloneFirstTemplateModel({
providerId: "test-provider",
modelId: "next-model",
templateIds: ["missing"],
ctx: createContext([]),
});
expect(model).toBeUndefined();
it.each([
{
name: "clones the first matching template and applies patches",
params: {
providerId: "test-provider",
modelId: " next-model ",
templateIds: ["missing", "template-a", "template-b"],
ctx: createContext([
{
id: "template-a",
name: "Template A",
provider: "test-provider",
api: "openai-completions",
} as ProviderRuntimeModel,
]),
patch: { reasoning: true },
},
expected: {
id: "next-model",
name: "next-model",
provider: "test-provider",
api: "openai-completions",
reasoning: true,
},
},
{
name: "returns undefined when no template exists",
params: {
providerId: "test-provider",
modelId: "next-model",
templateIds: ["missing"],
ctx: createContext([]),
},
expected: undefined,
},
] as const)("$name", ({ params, expected }) => {
const model = cloneFirstTemplateModel(params);
if (expected == null) {
expect(model).toBeUndefined();
return;
}
expect(model).toMatchObject(expected);
});
});
describe("matchesExactOrPrefix", () => {
it("matches exact ids and prefixed variants case-insensitively", () => {
expect(matchesExactOrPrefix("MiniMax-M2.7", ["minimax-m2.7"])).toBe(true);
expect(matchesExactOrPrefix("minimax-m2.7-highspeed", ["MiniMax-M2.7"])).toBe(true);
expect(matchesExactOrPrefix("glm-5", ["minimax-m2.7"])).toBe(false);
it.each([
["MiniMax-M2.7", ["minimax-m2.7"], true],
["minimax-m2.7-highspeed", ["MiniMax-M2.7"], true],
["glm-5", ["minimax-m2.7"], false],
] as const)("matches %s against prefixes", (id, candidates, expected) => {
expect(matchesExactOrPrefix(id, candidates)).toBe(expected);
});
});

View File

@@ -43,6 +43,41 @@ function expectServiceContext(
expect(typeof ctx.logger.error).toBe("function");
}
function createTrackingService(
id: string,
params: {
starts?: string[];
stops?: string[];
contexts?: OpenClawPluginServiceContext[];
failOnStart?: boolean;
failOnStop?: boolean;
stopSpy?: () => void;
} = {},
): OpenClawPluginService {
return {
id,
start: (ctx) => {
if (params.failOnStart) {
throw new Error("start failed");
}
params.starts?.push(id.at(-1) ?? id);
params.contexts?.push(ctx);
},
stop: params.stopSpy
? () => {
params.stopSpy?.();
}
: params.stops || params.failOnStop
? () => {
if (params.failOnStop) {
throw new Error("stop failed");
}
params.stops?.push(id.at(-1) ?? id);
}
: undefined,
};
}
describe("startPluginServices", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -53,37 +88,13 @@ describe("startPluginServices", () => {
const stops: string[] = [];
const contexts: OpenClawPluginServiceContext[] = [];
const serviceA: OpenClawPluginService = {
id: "service-a",
start: (ctx) => {
starts.push("a");
contexts.push(ctx);
},
stop: () => {
stops.push("a");
},
};
const serviceB: OpenClawPluginService = {
id: "service-b",
start: (ctx) => {
starts.push("b");
contexts.push(ctx);
},
};
const serviceC: OpenClawPluginService = {
id: "service-c",
start: (ctx) => {
starts.push("c");
contexts.push(ctx);
},
stop: () => {
stops.push("c");
},
};
const config = {} as Parameters<typeof startPluginServices>[0]["config"];
const handle = await startPluginServices({
registry: createRegistry([serviceA, serviceB, serviceC]),
registry: createRegistry([
createTrackingService("service-a", { starts, stops, contexts }),
createTrackingService("service-b", { starts, contexts }),
createTrackingService("service-c", { starts, stops, contexts }),
]),
config,
workspaceDir: "/tmp/workspace",
});
@@ -105,23 +116,12 @@ describe("startPluginServices", () => {
const handle = await startPluginServices({
registry: createRegistry([
{
id: "service-start-fail",
start: () => {
throw new Error("start failed");
},
stop: vi.fn(),
},
{
id: "service-ok",
start: () => undefined,
stop: stopOk,
},
{
id: "service-stop-fail",
start: () => undefined,
stop: stopThrows,
},
createTrackingService("service-start-fail", {
failOnStart: true,
stopSpy: vi.fn(),
}),
createTrackingService("service-ok", { stopSpy: stopOk }),
createTrackingService("service-stop-fail", { stopSpy: stopThrows }),
]),
config: {} as Parameters<typeof startPluginServices>[0]["config"],
});

View File

@@ -29,6 +29,21 @@ describe("applyExclusiveSlotSelection", () => {
},
});
function expectSelectionWarnings(
warnings: string[],
params: {
contains?: readonly string[];
excludes?: readonly string[];
},
) {
for (const warning of params.contains ?? []) {
expect(warnings).toContain(warning);
}
for (const warning of params.excludes ?? []) {
expect(warnings).not.toContain(warning);
}
}
it("selects the slot and disables other entries for the same kind", () => {
const config = createMemoryConfig({
slots: { memory: "memory-core" },
@@ -61,35 +76,38 @@ describe("applyExclusiveSlotSelection", () => {
expect(result.config).toBe(config);
});
it("warns when the slot falls back to a default", () => {
const config = createMemoryConfig();
const result = applyExclusiveSlotSelection({
config,
it.each([
{
name: "warns when the slot falls back to a default",
config: createMemoryConfig(),
selectedId: "memory",
selectedKind: "memory",
registry: { plugins: [{ id: "memory", kind: "memory" }] },
});
expect(result.changed).toBe(true);
expect(result.warnings).toContain(
'Exclusive slot "memory" switched from "memory-core" to "memory".',
);
});
it("keeps disabled competing plugins disabled without adding disable warnings", () => {
const config = createMemoryConfig({
entries: {
"memory-core": { enabled: false },
expectedDisabled: undefined,
warningChecks: {
contains: ['Exclusive slot "memory" switched from "memory-core" to "memory".'],
},
});
const result = runMemorySelection(config);
},
{
name: "keeps disabled competing plugins disabled without adding disable warnings",
config: createMemoryConfig({
entries: {
"memory-core": { enabled: false },
},
}),
selectedId: "memory",
expectedDisabled: false,
warningChecks: {
contains: ['Exclusive slot "memory" switched from "memory-core" to "memory".'],
excludes: ['Disabled other "memory" slot plugins: memory-core.'],
},
},
] as const)("$name", ({ config, selectedId, expectedDisabled, warningChecks }) => {
const result = runMemorySelection(config, selectedId);
expect(result.changed).toBe(true);
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
expect(result.warnings).toContain(
'Exclusive slot "memory" switched from "memory-core" to "memory".',
);
expect(result.warnings).not.toContain('Disabled other "memory" slot plugins: memory-core.');
if (expectedDisabled != null) {
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(expectedDisabled);
}
expectSelectionWarnings(result.warnings, warningChecks);
});
it("skips changes when no exclusive slot applies", () => {