Files
openclaw/src/cli/plugins-install-persist.test.ts
2026-05-03 13:59:21 +01:00

570 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
applyExclusiveSlotSelection,
buildPluginDiagnosticsReport,
buildPluginSnapshotReport,
clearPluginRegistryLoadCache,
enablePluginInConfig,
loadPluginManifestRegistry,
replaceConfigFile,
refreshPluginRegistry,
resetPluginsCliTestState,
runtimeLogs,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
} from "./plugins-cli-test-helpers.js";
describe("persistPluginInstall", () => {
beforeEach(() => {
resetPluginsCliTestState();
});
it("adds installed plugins to restrictive allowlists before enabling", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
allow: ["memory-core"],
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
allow: ["alpha", "memory-core"],
entries: {
alpha: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockImplementation((...args: unknown[]) => {
const [cfg, pluginId] = args as [OpenClawConfig, string];
expect(pluginId).toBe("alpha");
expect(cfg.plugins?.allow).toEqual(["alpha", "memory-core"]);
return { config: enabledConfig };
});
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "alpha",
install: {
source: "npm",
spec: "alpha@1.0.0",
installPath: "/tmp/alpha",
},
});
expect(next).toEqual(enabledConfig);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
alpha: expect.objectContaining({
source: "npm",
spec: "alpha@1.0.0",
installPath: "/tmp/alpha",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledConfig);
expect(replaceConfigFile).toHaveBeenCalledWith({
nextConfig: enabledConfig,
baseHash: "config-1",
writeOptions: {
afterWrite: { mode: "restart", reason: "plugin source changed" },
unsetPaths: [["plugins", "installs"]],
},
});
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: enabledConfig,
installRecords: {
alpha: expect.objectContaining({
source: "npm",
spec: "alpha@1.0.0",
installPath: "/tmp/alpha",
}),
},
reason: "source-changed",
});
expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1);
});
it("persists installs even when runtime cache invalidation fails", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
alpha: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
clearPluginRegistryLoadCache.mockImplementation(() => {
throw new Error("cache unavailable");
});
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "alpha",
install: {
source: "npm",
spec: "alpha@1.0.0",
installPath: "/tmp/alpha",
},
});
expect(next).toEqual(enabledConfig);
expect(refreshPluginRegistry).toHaveBeenCalled();
expect(
runtimeLogs.some((line) => line.includes("Plugin runtime cache invalidation failed")),
).toBe(true);
});
it("warns when an installed npm plugin remains shadowed by a config-selected source", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
discord: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
buildPluginSnapshotReport.mockReturnValue({
plugins: [
{
id: "discord",
origin: "config",
source: "/tmp/openclaw-upstream/extensions/discord/index.ts",
status: "error",
},
],
diagnostics: [],
});
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "discord",
install: {
source: "npm",
spec: "@openclaw/discord",
installPath: "/tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts",
},
});
expect(next).toEqual(enabledConfig);
expect(buildPluginSnapshotReport).toHaveBeenCalledWith({
config: enabledConfig,
effectiveOnly: true,
onlyPluginIds: ["discord"],
});
expect(runtimeLogs.join("\n")).toContain(
'Warning: installed plugin "discord" is not the active source',
);
expect(runtimeLogs.join("\n")).toContain(
"active config source: /tmp/openclaw-upstream/extensions/discord/index.ts",
);
expect(runtimeLogs.join("\n")).toContain(
"installed npm source: /tmp/openclaw/npm/node_modules/@openclaw/discord/index.ts",
);
expect(runtimeLogs.join("\n")).toContain("openclaw plugins doctor");
});
it("does not warn when the config-selected source is inside the npm install path", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
discord: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
buildPluginSnapshotReport.mockReturnValue({
plugins: [
{
id: "discord",
origin: "config",
source: "/tmp/openclaw/npm/node_modules/@openclaw/discord/dist/index.js",
status: "loaded",
},
],
diagnostics: [],
});
await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "discord",
install: {
source: "npm",
spec: "@openclaw/discord",
installPath: "/tmp/openclaw/npm/node_modules/@openclaw/discord",
},
});
expect(runtimeLogs.join("\n")).not.toContain("is not the active source");
});
it("invalidates runtime cache even when registry refresh fails", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
alpha: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
refreshPluginRegistry.mockRejectedValueOnce(new Error("registry unavailable"));
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "alpha",
install: {
source: "npm",
spec: "alpha@1.0.0",
installPath: "/tmp/alpha",
},
});
expect(next).toEqual(enabledConfig);
expect(refreshPluginRegistry).toHaveBeenCalled();
expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1);
expect(runtimeLogs.some((line) => line.includes("Plugin registry refresh failed"))).toBe(true);
});
it("removes stale denylist entries before enabling installed plugins", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
deny: ["alpha", "other"],
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
deny: ["other"],
entries: {
alpha: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockImplementation((...args: unknown[]) => {
const [cfg, pluginId] = args as [OpenClawConfig, string];
expect(pluginId).toBe("alpha");
expect(cfg.plugins?.deny).toEqual(["other"]);
return { config: enabledConfig };
});
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "alpha",
install: {
source: "npm",
spec: "alpha@1.0.0",
installPath: "/tmp/alpha",
},
});
expect(next).toEqual(enabledConfig);
});
it("scopes runtime kind lookup to the selected plugin when metadata omits kind", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {
"legacy-memory-a": { enabled: true },
},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
"legacy-memory-a": { enabled: true },
"legacy-memory": { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "legacy-memory" }],
diagnostics: [],
});
buildPluginDiagnosticsReport.mockReturnValueOnce({
plugins: [{ id: "legacy-memory", kind: "memory" }],
diagnostics: [],
});
applyExclusiveSlotSelection.mockImplementation(((params: {
config: OpenClawConfig;
selectedId: string;
selectedKind?: string;
registry?: { plugins: Array<{ id: string; kind?: string }> };
}) => {
expect(params.selectedId).toBe("legacy-memory");
expect(params.selectedKind).toBe("memory");
expect(params.registry?.plugins).toEqual([{ id: "legacy-memory", kind: "memory" }]);
return {
config: {
...params.config,
plugins: {
...params.config.plugins,
slots: {
...params.config.plugins?.slots,
memory: "legacy-memory",
},
},
},
warnings: [],
changed: true,
};
}) as (...args: unknown[]) => unknown);
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "legacy-memory",
install: {
source: "path",
sourcePath: "/tmp/legacy-memory",
installPath: "/tmp/legacy-memory",
},
});
expect(buildPluginDiagnosticsReport).toHaveBeenCalledTimes(1);
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({
config: enabledConfig,
onlyPluginIds: ["legacy-memory"],
});
expect(loadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: enabledConfig,
}),
);
expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true);
expect(next.plugins?.slots?.memory).toBe("legacy-memory");
});
it("uses cold metadata for manifest-kind slot selection without loading runtime siblings", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {
"legacy-memory-a": { enabled: true },
},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
"legacy-memory-a": { enabled: true },
"memory-b": { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "memory-b", kind: "memory" }],
diagnostics: [],
});
applyExclusiveSlotSelection.mockImplementation(((params: {
config: OpenClawConfig;
selectedId: string;
selectedKind?: string;
registry?: { plugins: Array<{ id: string; kind?: string }> };
}) => {
expect(params.selectedId).toBe("memory-b");
expect(params.selectedKind).toBe("memory");
expect(params.registry?.plugins).toEqual([{ id: "memory-b", kind: "memory" }]);
return {
config: {
...params.config,
plugins: {
...params.config.plugins,
slots: {
...params.config.plugins?.slots,
memory: "memory-b",
},
},
},
warnings: [],
changed: true,
};
}) as (...args: unknown[]) => unknown);
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "memory-b",
install: {
source: "path",
sourcePath: "/tmp/memory-b",
installPath: "/tmp/memory-b",
},
});
expect(buildPluginDiagnosticsReport).not.toHaveBeenCalled();
expect(loadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: enabledConfig,
}),
);
expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true);
expect(next.plugins?.slots?.memory).toBe("memory-b");
});
it("does not load every plugin runtime for non-slot installs without manifest kind", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
plain: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "plain" }],
diagnostics: [],
});
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "plain" }],
diagnostics: [],
});
applyExclusiveSlotSelection.mockReturnValue({
config: enabledConfig,
warnings: [],
changed: false,
});
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "plain",
install: {
source: "path",
sourcePath: "/tmp/plain",
installPath: "/tmp/plain",
},
});
expect(buildPluginDiagnosticsReport).toHaveBeenCalledTimes(1);
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({
config: enabledConfig,
onlyPluginIds: ["plain"],
});
expect(loadPluginManifestRegistry).toHaveBeenCalledWith(
expect.objectContaining({
config: enabledConfig,
}),
);
expect(next).toEqual(enabledConfig);
});
it("can persist an install record without enabling a plugin that needs config first", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "memory-lancedb",
enable: false,
install: {
source: "path",
spec: "memory-lancedb",
sourcePath: "/app/dist/extensions/memory-lancedb",
installPath: "/app/dist/extensions/memory-lancedb",
},
});
expect(next).toEqual(baseConfig);
expect(enablePluginInConfig).not.toHaveBeenCalled();
expect(applyExclusiveSlotSelection).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
"memory-lancedb": expect.objectContaining({
source: "path",
sourcePath: "/app/dist/extensions/memory-lancedb",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(baseConfig);
});
it("does not add disabled installs to restrictive allowlists", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
allow: ["memory-core"],
deny: ["memory-lancedb"],
},
} as OpenClawConfig;
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "memory-lancedb",
enable: false,
install: {
source: "path",
spec: "memory-lancedb",
sourcePath: "/app/dist/extensions/memory-lancedb",
installPath: "/app/dist/extensions/memory-lancedb",
},
});
expect(next.plugins?.allow).toEqual(["memory-core"]);
expect(next.plugins?.deny).toEqual(["memory-lancedb"]);
expect(next.plugins?.entries?.["memory-lancedb"]).toBeUndefined();
});
});