Files
moltbot/src/cli/plugins-cli.update.test.ts
2026-05-12 07:23:49 +01:00

364 lines
11 KiB
TypeScript

import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
loadConfig,
refreshPluginRegistry,
registerPluginsCli,
resetPluginsCliTestState,
runPluginsCommand,
runtimeErrors,
runtimeLogs,
setInstalledPluginIndexInstallRecords,
updateNpmInstalledHookPacks,
updateNpmInstalledPlugins,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
} from "./plugins-cli-test-helpers.js";
const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
function createTrackedPluginConfig(params: {
pluginId: string;
spec: string;
resolvedName?: string;
}): OpenClawConfig {
return {
plugins: {
installs: {
[params.pluginId]: {
source: "npm",
spec: params.spec,
installPath: `/tmp/${params.pluginId}`,
...(params.resolvedName ? { resolvedName: params.resolvedName } : {}),
},
},
},
} as OpenClawConfig;
}
function expectRestartNoticeLogged() {
expect(
runtimeLogs.some((message) =>
message.includes("Restart the gateway to load plugins and hooks."),
),
).toBe(true);
}
function expectSingleCallParams(mockFn: ReturnType<typeof vi.fn>) {
expect(mockFn).toHaveBeenCalledTimes(1);
const params = mockFn.mock.calls.at(0)?.[0] as Record<string, unknown> | undefined;
if (params === undefined) {
throw new Error("expected call params");
}
return params;
}
describe("plugins cli update", () => {
beforeEach(() => {
resetPluginsCliTestState();
});
afterEach(() => {
if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) {
delete process.env.OPENCLAW_NIX_MODE;
} else {
process.env.OPENCLAW_NIX_MODE = ORIGINAL_OPENCLAW_NIX_MODE;
}
});
it("shows the dangerous unsafe install override in update help", () => {
const program = new Command();
registerPluginsCli(program);
const pluginsCommand = program.commands.find((command) => command.name() === "plugins");
const updateCommand = pluginsCommand?.commands.find((command) => command.name() === "update");
const helpText = updateCommand?.helpInformation() ?? "";
expect(helpText).toContain("--dangerously-force-unsafe-install");
expect(helpText).toContain("Bypass built-in dangerous-code update");
expect(helpText).toContain("blocking for plugins");
});
it("refuses plugin updates in Nix mode before package-manager work", async () => {
const previous = process.env.OPENCLAW_NIX_MODE;
process.env.OPENCLAW_NIX_MODE = "1";
try {
await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow(
"OPENCLAW_NIX_MODE=1",
);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_NIX_MODE;
} else {
process.env.OPENCLAW_NIX_MODE = previous;
}
}
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("updates tracked hook packs through plugins update", async () => {
const cfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.0.0",
installPath: "/tmp/hooks/demo-hooks",
resolvedName: "@acme/demo-hooks",
},
},
},
},
} as OpenClawConfig;
const nextConfig = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.1.0",
installPath: "/tmp/hooks/demo-hooks",
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
updateNpmInstalledPlugins.mockResolvedValue({
config: cfg,
changed: false,
outcomes: [],
});
updateNpmInstalledHookPacks.mockResolvedValue({
config: nextConfig,
changed: true,
outcomes: [
{
hookId: "demo-hooks",
status: "updated",
message: 'Updated hook pack "demo-hooks": 1.0.0 -> 1.1.0.',
},
],
});
await runPluginsCommand(["plugins", "update", "demo-hooks"]);
const hookUpdateParams = expectSingleCallParams(updateNpmInstalledHookPacks);
expect(hookUpdateParams.config).toBe(cfg);
expect(hookUpdateParams.hookIds).toEqual(["demo-hooks"]);
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expectRestartNoticeLogged();
});
it("exits when update is called without id and without --all", async () => {
loadConfig.mockReturnValue({
plugins: {
installs: {},
},
} as OpenClawConfig);
await expect(runPluginsCommand(["plugins", "update"])).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain("Provide a plugin or hook-pack id, or use --all.");
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
});
it("reports no tracked plugins or hook packs when update --all has empty install records", async () => {
loadConfig.mockReturnValue({
plugins: {
installs: {},
},
} as OpenClawConfig);
await runPluginsCommand(["plugins", "update", "--all"]);
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(runtimeLogs.at(-1)).toBe("No tracked plugins or hook packs to update.");
});
it("passes dangerous force unsafe install to plugin updates", async () => {
const config = createTrackedPluginConfig({
pluginId: "openclaw-codex-app-server",
spec: "openclaw-codex-app-server@beta",
});
loadConfig.mockReturnValue(config);
setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
config,
changed: false,
outcomes: [],
});
await runPluginsCommand([
"plugins",
"update",
"openclaw-codex-app-server",
"--dangerously-force-unsafe-install",
]);
const updateParams = expectSingleCallParams(updateNpmInstalledPlugins);
expect(updateParams.config).toEqual(config);
expect(updateParams.pluginIds).toEqual(["openclaw-codex-app-server"]);
expect(updateParams.dangerouslyForceUnsafeInstall).toBe(true);
});
it("writes updated config when updater reports changes", async () => {
const cfg = {
plugins: {
installs: {
alpha: {
source: "npm",
spec: "@openclaw/alpha@1.0.0",
},
},
},
} as OpenClawConfig;
const nextConfig = {
plugins: {
installs: {
alpha: {
source: "npm",
spec: "@openclaw/alpha@1.1.0",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }],
changed: true,
config: nextConfig,
});
updateNpmInstalledHookPacks.mockResolvedValue({
outcomes: [],
changed: false,
config: nextConfig,
});
await runPluginsCommand(["plugins", "update", "alpha"]);
const updateParams = expectSingleCallParams(updateNpmInstalledPlugins);
expect(updateParams.config).toEqual(cfg);
expect(updateParams.pluginIds).toEqual(["alpha"]);
expect(updateParams.dryRun).toBe(false);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextConfig.plugins?.installs,
);
expect(writeConfigFile).toHaveBeenCalledWith({});
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: {},
installRecords: nextConfig.plugins?.installs,
reason: "source-changed",
});
expectRestartNoticeLogged();
});
it("exits non-zero when a plugin update reports an error after persisting successes", async () => {
const cfg = {
plugins: {
installs: {
alpha: {
source: "npm",
spec: "@openclaw/alpha@1.0.0",
},
beta: {
source: "npm",
spec: "@openclaw/beta@1.0.0",
},
},
},
} as OpenClawConfig;
const nextConfig = {
plugins: {
installs: {
alpha: {
source: "npm",
spec: "@openclaw/alpha@1.1.0",
},
beta: {
source: "npm",
spec: "@openclaw/beta@1.0.0",
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
outcomes: [
{ pluginId: "alpha", status: "updated", message: "Updated alpha -> 1.1.0" },
{ pluginId: "beta", status: "error", message: "Failed to update beta: registry timeout" },
],
changed: true,
config: nextConfig,
});
updateNpmInstalledHookPacks.mockResolvedValue({
outcomes: [],
changed: false,
config: nextConfig,
});
await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1");
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextConfig.plugins?.installs,
);
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: {},
installRecords: nextConfig.plugins?.installs,
reason: "source-changed",
});
expect(runtimeLogs).toContain("Failed to update beta: registry timeout");
});
it("exits non-zero when a hook pack update reports an error", async () => {
const cfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.0.0",
installPath: "/tmp/hooks/demo-hooks",
resolvedName: "@acme/demo-hooks",
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
updateNpmInstalledPlugins.mockResolvedValue({
config: cfg,
changed: false,
outcomes: [],
});
updateNpmInstalledHookPacks.mockResolvedValue({
config: cfg,
changed: false,
outcomes: [
{
hookId: "demo-hooks",
status: "error",
message: 'Failed to update hook pack "demo-hooks": registry timeout',
},
],
});
await expect(runPluginsCommand(["plugins", "update", "demo-hooks"])).rejects.toThrow(
"__exit__:1",
);
expect(writeConfigFile).not.toHaveBeenCalled();
expect(runtimeLogs).toContain('Failed to update hook pack "demo-hooks": registry timeout');
});
});