Files
openclaw/src/config/runtime-schema.test.ts
2026-05-08 05:28:12 +01:00

326 lines
11 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import {
getActivePluginRegistry,
getActivePluginRegistryKey,
getActivePluginRegistryVersion,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "../plugins/runtime.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js";
const mockLoadConfig = vi.hoisted(() => vi.fn<() => OpenClawConfig>());
const mockReadConfigFileSnapshot = vi.hoisted(() => vi.fn<() => Promise<ConfigFileSnapshot>>());
const mockLoadPluginManifestRegistry = vi.hoisted(() => vi.fn());
const mockGetCurrentPluginMetadataSnapshot = vi.hoisted(() => vi.fn());
let readBestEffortRuntimeConfigSchema: typeof import("./runtime-schema.js").readBestEffortRuntimeConfigSchema;
let loadGatewayRuntimeConfigSchema: typeof import("./runtime-schema.js").loadGatewayRuntimeConfigSchema;
vi.mock("./config.js", () => {
return {
getRuntimeConfig: () => mockLoadConfig(),
loadConfig: () => mockLoadConfig(),
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
};
});
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: (...args: unknown[]) => mockLoadPluginManifestRegistry(...args),
}));
vi.mock("../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: (...args: unknown[]) =>
mockLoadPluginManifestRegistry(...args),
}));
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: (...args: unknown[]) => ({
manifestRegistry: mockLoadPluginManifestRegistry(...args),
}),
}));
vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: (...args: unknown[]) =>
mockGetCurrentPluginMetadataSnapshot(...args),
}));
function makeSnapshot(params: { valid: boolean; config?: OpenClawConfig }): ConfigFileSnapshot {
return {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: params.config ?? {},
resolved: params.config ?? {},
sourceConfig: params.config ?? {},
valid: params.valid,
config: params.config ?? {},
runtimeConfig: params.config ?? {},
issues: params.valid ? [] : [{ path: "gateway", message: "invalid" }],
warnings: [],
legacyIssues: [],
};
}
function makeManifestRegistry() {
return {
diagnostics: [],
plugins: [
{
id: "demo",
name: "Demo",
description: "Demo plugin",
origin: "bundled",
channels: [],
configUiHints: {},
configSchema: {
type: "object",
properties: {
mode: { type: "string" },
},
},
},
{
id: "telegram",
name: "Telegram",
description: "Telegram plugin",
origin: "bundled",
channels: ["telegram"],
channelCatalogMeta: {
id: "telegram",
label: "Telegram",
blurb: "Telegram channel",
},
channelConfigs: {
telegram: {
schema: {
type: "object",
properties: {
botToken: { type: "string" },
},
},
uiHints: {},
},
},
},
{
id: "slack",
name: "Slack",
description: "Slack plugin",
origin: "bundled",
channels: ["slack"],
channelCatalogMeta: {
id: "slack",
label: "Slack",
blurb: "Slack channel",
},
channelConfigs: {
slack: {
schema: {
type: "object",
properties: {
botToken: { type: "string" },
},
},
uiHints: {},
},
},
},
{
id: "matrix",
name: "Matrix",
description: "Matrix plugin",
origin: "workspace",
channels: ["matrix"],
channelCatalogMeta: {
id: "matrix",
label: "Matrix",
blurb: "Matrix channel",
},
channelConfigs: {
matrix: {
schema: {
type: "object",
properties: {
homeserver: { type: "string" },
},
},
uiHints: {},
},
},
},
],
};
}
async function readSchemaNodes() {
const result = await readBestEffortRuntimeConfigSchema();
const schema = result.schema as { properties?: Record<string, unknown> };
const channelsNode = schema.properties?.channels as Record<string, unknown> | undefined;
const channelProps = channelsNode?.properties as Record<string, unknown> | undefined;
const pluginsNode = schema.properties?.plugins as Record<string, unknown> | undefined;
const pluginProps = pluginsNode?.properties as Record<string, unknown> | undefined;
const entriesNode = pluginProps?.entries as Record<string, unknown> | undefined;
const entryProps = entriesNode?.properties as Record<string, unknown> | undefined;
return { channelProps, entryProps };
}
beforeAll(async () => {
({ readBestEffortRuntimeConfigSchema, loadGatewayRuntimeConfigSchema } =
await import("./runtime-schema.js"));
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
describe("readBestEffortRuntimeConfigSchema", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadConfig.mockReturnValue({});
mockLoadPluginManifestRegistry.mockReturnValue(makeManifestRegistry());
});
it("merges manifest plugin metadata for valid configs", async () => {
mockReadConfigFileSnapshot.mockResolvedValueOnce(
makeSnapshot({
valid: true,
config: { plugins: { entries: { demo: { enabled: true } } } },
}),
);
const { channelProps, entryProps } = await readSchemaNodes();
expect(mockLoadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: { plugins: { entries: { demo: { enabled: true } } } },
}),
);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty("cache", false);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty(
"bundledChannelConfigCollector",
);
expect(channelProps).toHaveProperty("telegram");
expect(channelProps).toHaveProperty("matrix");
expect(entryProps).toHaveProperty("demo");
});
it("falls back to bundled channel metadata when config is invalid", async () => {
mockReadConfigFileSnapshot.mockResolvedValueOnce(makeSnapshot({ valid: false }));
const { channelProps, entryProps } = await readSchemaNodes();
expect(mockLoadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: { plugins: { enabled: true } },
}),
);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty("cache", false);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty(
"bundledChannelConfigCollector",
);
expect(channelProps).toHaveProperty("telegram");
expect(channelProps).toHaveProperty("slack");
expect(entryProps?.demo).toBeUndefined();
});
});
describe("loadGatewayRuntimeConfigSchema", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadConfig.mockReturnValue({ plugins: { entries: { demo: { enabled: true } } } });
mockLoadPluginManifestRegistry.mockReturnValue(makeManifestRegistry());
});
it("uses manifest metadata instead of booting plugin runtime", () => {
const result = loadGatewayRuntimeConfigSchema();
const schema = result.schema as { properties?: Record<string, unknown> };
const channelsNode = schema.properties?.channels as Record<string, unknown> | undefined;
const channelProps = channelsNode?.properties as Record<string, unknown> | undefined;
expect(mockLoadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: { plugins: { entries: { demo: { enabled: true } } } },
}),
);
expect(mockLoadPluginManifestRegistry.mock.calls[0]?.[0]).not.toHaveProperty(
"bundledChannelConfigCollector",
);
expect(channelProps).toHaveProperty("telegram");
expect(channelProps).toHaveProperty("matrix");
});
it("reuses the current gateway plugin metadata snapshot for config schema requests", () => {
mockGetCurrentPluginMetadataSnapshot.mockReturnValueOnce({
manifestRegistry: {
diagnostics: [],
plugins: [
{
id: "telegram",
name: "Telegram",
description: "Telegram plugin",
origin: "bundled",
channels: ["telegram"],
},
{
id: "matrix",
name: "Matrix",
description: "Matrix plugin",
origin: "workspace",
channels: ["matrix"],
channelConfigs: {
matrix: {
schema: {
type: "object",
properties: {
homeserver: { type: "string" },
},
},
},
},
},
],
},
});
const result = loadGatewayRuntimeConfigSchema();
const schema = result.schema as { properties?: Record<string, unknown> };
const channelsNode = schema.properties?.channels as Record<string, unknown> | undefined;
const channelProps = channelsNode?.properties as Record<string, unknown> | undefined;
expect(mockGetCurrentPluginMetadataSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
config: { plugins: { entries: { demo: { enabled: true } } } },
}),
);
expect(mockLoadPluginManifestRegistry).not.toHaveBeenCalled();
expect(channelProps).toHaveProperty("telegram");
expect(JSON.stringify(channelProps?.telegram)).toContain("botToken");
expect(channelProps).toHaveProperty("matrix");
});
it("does not activate or replace the active plugin registry across repeated schema loads (regression guard for #54816)", () => {
// Each MCP connection triggers a config.schema / config.get gateway request which calls
// loadGatewayRuntimeConfigSchema. The original bug caused a fresh full plugin registry to
// be activated on every call, re-running registerFull for all channel plugins including
// Feishu. Verify that repeated calls keep using manifest metadata without replacing the
// already-active runtime registry or mutating its activation version.
const activeRegistry = createEmptyPluginRegistry();
setActivePluginRegistry(activeRegistry, "startup-registry");
const versionBefore = getActivePluginRegistryVersion();
loadGatewayRuntimeConfigSchema();
loadGatewayRuntimeConfigSchema();
loadGatewayRuntimeConfigSchema();
expect(mockLoadPluginManifestRegistry).toHaveBeenCalledTimes(3);
for (const call of mockLoadPluginManifestRegistry.mock.calls) {
expect(call[0]).toHaveProperty("config");
expect(call[0]).not.toHaveProperty("bundledChannelConfigCollector");
}
expect(getActivePluginRegistry()).toBe(activeRegistry);
expect(getActivePluginRegistryKey()).toBe("startup-registry");
expect(getActivePluginRegistryVersion()).toBe(versionBefore);
});
});