Files
moltbot/src/cli/plugins-install-config.test.ts
2026-05-02 23:48:54 +01:00

221 lines
7.6 KiB
TypeScript

import { bundledPluginRootAt, repoInstallSpec } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ConfigFileSnapshot } from "../config/types.openclaw.js";
import {
resolvePluginInstallRequestContext,
type PluginInstallRequestContext,
} from "./plugin-install-config-policy.js";
import { loadConfigForInstall } from "./plugins-install-command.js";
const hoisted = vi.hoisted(() => ({
readConfigFileSnapshotMock: vi.fn<() => Promise<ConfigFileSnapshot>>(),
collectChannelDoctorStaleConfigMutationsMock: vi.fn(),
}));
const readConfigFileSnapshotMock = hoisted.readConfigFileSnapshotMock;
const collectChannelDoctorStaleConfigMutationsMock =
hoisted.collectChannelDoctorStaleConfigMutationsMock;
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => readConfigFileSnapshotMock(),
}));
vi.mock("../commands/doctor/shared/channel-doctor.js", () => ({
collectChannelDoctorStaleConfigMutations: (cfg: OpenClawConfig) =>
collectChannelDoctorStaleConfigMutationsMock(cfg),
}));
const DISCORD_REPO_INSTALL_SPEC = repoInstallSpec("discord");
function makeSnapshot(overrides: Partial<ConfigFileSnapshot> = {}): ConfigFileSnapshot {
return {
path: "/tmp/config.json5",
exists: true,
raw: '{ "plugins": {} }',
parsed: { plugins: {} },
sourceConfig: { plugins: {} } as ConfigFileSnapshot["sourceConfig"],
resolved: { plugins: {} } as OpenClawConfig,
valid: false,
runtimeConfig: { plugins: {} } as ConfigFileSnapshot["runtimeConfig"],
config: { plugins: {} } as OpenClawConfig,
hash: "abc",
issues: [{ path: "plugins.installs.discord", message: "stale path" }],
warnings: [],
legacyIssues: [],
...overrides,
};
}
describe("loadConfigForInstall", () => {
const discordNpmRequest = {
rawSpec: "@openclaw/discord",
normalizedSpec: "@openclaw/discord",
bundledPluginId: "discord",
allowInvalidConfigRecovery: true,
} satisfies PluginInstallRequestContext;
beforeEach(() => {
readConfigFileSnapshotMock.mockReset();
collectChannelDoctorStaleConfigMutationsMock.mockReset();
collectChannelDoctorStaleConfigMutationsMock.mockImplementation(async (cfg: OpenClawConfig) => [
{
config: cfg,
changes: [],
},
]);
});
it("returns the source config and base hash when the snapshot is valid", async () => {
const cfg = { plugins: { entries: { discord: { enabled: true } } } } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
sourceConfig: cfg,
config: { plugins: { entries: { discord: { enabled: true } }, enabled: true } },
hash: "config-1",
issues: [],
}),
);
const result = await loadConfigForInstall(discordNpmRequest);
expect(result).toEqual({ config: cfg, baseHash: "config-1" });
});
it("does not run stale Discord cleanup on the happy path", async () => {
const cfg = { plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
valid: true,
sourceConfig: cfg,
config: cfg,
issues: [],
}),
);
const result = await loadConfigForInstall(discordNpmRequest);
expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled();
expect(result.config).toBe(cfg);
});
it("falls back to snapshot config for explicit bundled-plugin reinstall when issues match the known upgrade failure", async () => {
const snapshotCfg = {
plugins: { installs: { discord: { source: "path", installPath: "/gone" } } },
} as unknown as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { installs: { discord: {} } } },
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
],
}),
);
const result = await loadConfigForInstall(discordNpmRequest);
expect(readConfigFileSnapshotMock).toHaveBeenCalled();
expect(collectChannelDoctorStaleConfigMutationsMock).toHaveBeenCalledWith(snapshotCfg);
expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" });
});
it("allows npm:-prefixed bundled-plugin reinstall recovery", async () => {
const snapshotCfg = {
plugins: { installs: { discord: { source: "path", installPath: "/gone" } } },
} as unknown as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: { plugins: { installs: { discord: {} } } },
config: snapshotCfg,
issues: [
{ path: "channels.discord", message: "unknown channel id: discord" },
{ path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" },
],
}),
);
const request = resolvePluginInstallRequestContext({
rawSpec: "npm:@openclaw/discord",
});
if (!request.ok) {
throw new Error(request.error);
}
expect(request.request).toMatchObject({
bundledPluginId: "discord",
allowInvalidConfigRecovery: true,
});
const result = await loadConfigForInstall(request.request);
expect(collectChannelDoctorStaleConfigMutationsMock).toHaveBeenCalledWith(snapshotCfg);
expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" });
});
it("allows explicit repo-checkout bundled-plugin reinstall recovery", async () => {
const snapshotCfg = { plugins: {} } as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
config: snapshotCfg,
issues: [{ path: "channels.discord", message: "unknown channel id: discord" }],
}),
);
const repoRequest = resolvePluginInstallRequestContext({
rawSpec: DISCORD_REPO_INSTALL_SPEC,
});
if (!repoRequest.ok) {
throw new Error(repoRequest.error);
}
const result = await loadConfigForInstall({
...repoRequest.request,
resolvedPath: bundledPluginRootAt("/tmp/repo", "discord"),
});
expect(result.config).toBe(snapshotCfg);
});
it("rejects unrelated invalid config even during bundled-plugin reinstall recovery", async () => {
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
issues: [{ path: "models.default", message: "invalid model ref" }],
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config invalid outside the bundled recovery path for discord",
);
});
it("rejects non-Discord install requests when config is invalid", async () => {
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
await expect(
loadConfigForInstall({
rawSpec: "alpha",
normalizedSpec: "alpha",
}),
).rejects.toThrow("Config invalid; run `openclaw doctor --fix` before installing plugins.");
});
it("throws when invalid snapshot parsed is empty", async () => {
readConfigFileSnapshotMock.mockResolvedValue(
makeSnapshot({
parsed: {},
config: {} as OpenClawConfig,
}),
);
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config file could not be parsed; run `openclaw doctor` to repair it.",
);
});
it("throws when invalid snapshot config file does not exist", async () => {
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot({ exists: false, parsed: {} }));
await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow(
"Config file could not be parsed; run `openclaw doctor` to repair it.",
);
});
});