mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 13:44:03 +00:00
test(cli): split plugin cli test coverage by surface
This commit is contained in:
224
src/cli/plugins-cli-test-helpers.ts
Normal file
224
src/cli/plugins-cli-test-helpers.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Command } from "commander";
|
||||
import { vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
|
||||
|
||||
export const loadConfig = vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig);
|
||||
export const writeConfigFile = vi.fn<(config: OpenClawConfig) => Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
export const resolveStateDir = vi.fn(() => "/tmp/openclaw-state");
|
||||
export const installPluginFromMarketplace = vi.fn();
|
||||
export const listMarketplacePlugins = vi.fn();
|
||||
export const resolveMarketplaceInstallShortcut = vi.fn();
|
||||
export const enablePluginInConfig = vi.fn();
|
||||
export const recordPluginInstall = vi.fn();
|
||||
export const clearPluginManifestRegistryCache = vi.fn();
|
||||
export const buildPluginStatusReport = vi.fn();
|
||||
export const applyExclusiveSlotSelection = vi.fn();
|
||||
export const uninstallPlugin = vi.fn();
|
||||
export const updateNpmInstalledPlugins = vi.fn();
|
||||
export const updateNpmInstalledHookPacks = vi.fn();
|
||||
export const promptYesNo = vi.fn();
|
||||
export const installPluginFromNpmSpec = vi.fn();
|
||||
export const installPluginFromPath = vi.fn();
|
||||
export const installPluginFromClawHub = vi.fn();
|
||||
export const parseClawHubPluginSpec = vi.fn();
|
||||
export const installHooksFromNpmSpec = vi.fn();
|
||||
export const installHooksFromPath = vi.fn();
|
||||
export const recordHookInstall = vi.fn();
|
||||
|
||||
const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } =
|
||||
createCliRuntimeCapture();
|
||||
|
||||
export { runtimeErrors, runtimeLogs };
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => loadConfig(),
|
||||
writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config),
|
||||
}));
|
||||
|
||||
vi.mock("../config/paths.js", () => ({
|
||||
resolveStateDir: () => resolveStateDir(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/marketplace.js", () => ({
|
||||
installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplace(...args),
|
||||
listMarketplacePlugins: (...args: unknown[]) => listMarketplacePlugins(...args),
|
||||
resolveMarketplaceInstallShortcut: (...args: unknown[]) =>
|
||||
resolveMarketplaceInstallShortcut(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/enable.js", () => ({
|
||||
enablePluginInConfig: (...args: unknown[]) => enablePluginInConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/installs.js", () => ({
|
||||
recordPluginInstall: (...args: unknown[]) => recordPluginInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", () => ({
|
||||
clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/status.js", () => ({
|
||||
buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/slots.js", () => ({
|
||||
applyExclusiveSlotSelection: (...args: unknown[]) => applyExclusiveSlotSelection(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/uninstall.js", () => ({
|
||||
uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args),
|
||||
resolveUninstallDirectoryTarget: ({
|
||||
installRecord,
|
||||
}: {
|
||||
installRecord?: { installPath?: string; sourcePath?: string };
|
||||
}) => installRecord?.installPath ?? installRecord?.sourcePath ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/update.js", () => ({
|
||||
updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/update.js", () => ({
|
||||
updateNpmInstalledHookPacks: (...args: unknown[]) => updateNpmInstalledHookPacks(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./prompt.js", () => ({
|
||||
promptYesNo: (...args: unknown[]) => promptYesNo(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/install.js", () => ({
|
||||
PLUGIN_INSTALL_ERROR_CODE: {
|
||||
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
|
||||
},
|
||||
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args),
|
||||
installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/install.js", () => ({
|
||||
installHooksFromNpmSpec: (...args: unknown[]) => installHooksFromNpmSpec(...args),
|
||||
installHooksFromPath: (...args: unknown[]) => installHooksFromPath(...args),
|
||||
resolveHookInstallDir: (hookId: string) => `/tmp/hooks/${hookId}`,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/installs.js", () => ({
|
||||
recordHookInstall: (...args: unknown[]) => recordHookInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/clawhub.js", () => ({
|
||||
CLAWHUB_INSTALL_ERROR_CODE: {
|
||||
PACKAGE_NOT_FOUND: "package_not_found",
|
||||
VERSION_NOT_FOUND: "version_not_found",
|
||||
},
|
||||
installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHub(...args),
|
||||
formatClawHubSpecifier: ({ name, version }: { name: string; version?: string }) =>
|
||||
`clawhub:${name}${version ? `@${version}` : ""}`,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/clawhub.js", () => ({
|
||||
parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpec(...args),
|
||||
}));
|
||||
|
||||
const { registerPluginsCli } = await import("./plugins-cli.js");
|
||||
|
||||
export function runPluginsCommand(argv: string[]) {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerPluginsCli(program);
|
||||
return program.parseAsync(argv, { from: "user" });
|
||||
}
|
||||
|
||||
export function resetPluginsCliTestState() {
|
||||
resetRuntimeCapture();
|
||||
loadConfig.mockReset();
|
||||
writeConfigFile.mockReset();
|
||||
resolveStateDir.mockReset();
|
||||
installPluginFromMarketplace.mockReset();
|
||||
listMarketplacePlugins.mockReset();
|
||||
resolveMarketplaceInstallShortcut.mockReset();
|
||||
enablePluginInConfig.mockReset();
|
||||
recordPluginInstall.mockReset();
|
||||
clearPluginManifestRegistryCache.mockReset();
|
||||
buildPluginStatusReport.mockReset();
|
||||
applyExclusiveSlotSelection.mockReset();
|
||||
uninstallPlugin.mockReset();
|
||||
updateNpmInstalledPlugins.mockReset();
|
||||
updateNpmInstalledHookPacks.mockReset();
|
||||
promptYesNo.mockReset();
|
||||
installPluginFromNpmSpec.mockReset();
|
||||
installPluginFromPath.mockReset();
|
||||
installPluginFromClawHub.mockReset();
|
||||
parseClawHubPluginSpec.mockReset();
|
||||
installHooksFromNpmSpec.mockReset();
|
||||
installHooksFromPath.mockReset();
|
||||
recordHookInstall.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({} as OpenClawConfig);
|
||||
writeConfigFile.mockResolvedValue(undefined);
|
||||
resolveStateDir.mockReturnValue("/tmp/openclaw-state");
|
||||
resolveMarketplaceInstallShortcut.mockResolvedValue(null);
|
||||
installPluginFromMarketplace.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "marketplace install failed",
|
||||
});
|
||||
enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg }));
|
||||
recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({
|
||||
config,
|
||||
warnings: [],
|
||||
}));
|
||||
uninstallPlugin.mockResolvedValue({
|
||||
ok: true,
|
||||
config: {} as OpenClawConfig,
|
||||
warnings: [],
|
||||
actions: {
|
||||
entry: false,
|
||||
install: false,
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
directory: false,
|
||||
},
|
||||
});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: {} as OpenClawConfig,
|
||||
});
|
||||
updateNpmInstalledHookPacks.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: {} as OpenClawConfig,
|
||||
});
|
||||
promptYesNo.mockResolvedValue(true);
|
||||
installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" });
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "npm install disabled in test",
|
||||
});
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "clawhub install disabled in test",
|
||||
});
|
||||
parseClawHubPluginSpec.mockReturnValue(null);
|
||||
installHooksFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "hook path install disabled in test",
|
||||
});
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "hook npm install disabled in test",
|
||||
});
|
||||
recordHookInstall.mockImplementation((cfg: OpenClawConfig) => cfg);
|
||||
}
|
||||
378
src/cli/plugins-cli.install.test.ts
Normal file
378
src/cli/plugins-cli.install.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
applyExclusiveSlotSelection,
|
||||
buildPluginStatusReport,
|
||||
clearPluginManifestRegistryCache,
|
||||
enablePluginInConfig,
|
||||
installHooksFromNpmSpec,
|
||||
installPluginFromClawHub,
|
||||
installPluginFromMarketplace,
|
||||
installPluginFromNpmSpec,
|
||||
loadConfig,
|
||||
parseClawHubPluginSpec,
|
||||
recordHookInstall,
|
||||
recordPluginInstall,
|
||||
resetPluginsCliTestState,
|
||||
runPluginsCommand,
|
||||
runtimeErrors,
|
||||
runtimeLogs,
|
||||
writeConfigFile,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
describe("plugins cli install", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginsCliTestState();
|
||||
});
|
||||
|
||||
it("exits when --marketplace is combined with --link", async () => {
|
||||
await expect(
|
||||
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain("`--link` is not supported with `--marketplace`.");
|
||||
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits when marketplace install fails", async () => {
|
||||
await expect(
|
||||
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installPluginFromMarketplace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
marketplace: "local/repo",
|
||||
plugin: "alpha",
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs marketplace plugins and persists config", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
...enabledCfg,
|
||||
plugins: {
|
||||
...enabledCfg.plugins,
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "marketplace",
|
||||
installPath: "/tmp/openclaw-state/extensions/alpha",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromMarketplace.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "alpha",
|
||||
targetDir: "/tmp/openclaw-state/extensions/alpha",
|
||||
version: "1.2.3",
|
||||
marketplaceName: "Claude",
|
||||
marketplaceSource: "local/repo",
|
||||
marketplacePlugin: "alpha",
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(installedCfg);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", kind: "provider" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: installedCfg,
|
||||
warnings: ["slot adjusted"],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]);
|
||||
|
||||
expect(clearPluginManifestRegistryCache).toHaveBeenCalledTimes(1);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true);
|
||||
expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true);
|
||||
});
|
||||
|
||||
it("installs ClawHub plugins and persists source metadata", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
...enabledCfg,
|
||||
plugins: {
|
||||
...enabledCfg.plugins,
|
||||
installs: {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo@1.2.3",
|
||||
installPath: "/tmp/openclaw-state/extensions/demo",
|
||||
clawhubPackage: "demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: "/tmp/openclaw-state/extensions/demo",
|
||||
version: "1.2.3",
|
||||
packageName: "demo",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
version: "1.2.3",
|
||||
integrity: "sha256-abc",
|
||||
resolvedAt: "2026-03-22T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(installedCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: installedCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
|
||||
|
||||
expect(installPluginFromClawHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:demo",
|
||||
}),
|
||||
);
|
||||
expect(recordPluginInstall).toHaveBeenCalledWith(
|
||||
enabledCfg,
|
||||
expect.objectContaining({
|
||||
pluginId: "demo",
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo@1.2.3",
|
||||
clawhubPackage: "demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
expect(runtimeLogs.some((line) => line.includes("Installed plugin: demo"))).toBe(true);
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers ClawHub before npm for bare plugin specs", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
...enabledCfg,
|
||||
plugins: {
|
||||
...enabledCfg.plugins,
|
||||
installs: {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo@1.2.3",
|
||||
installPath: "/tmp/openclaw-state/extensions/demo",
|
||||
clawhubPackage: "demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: "/tmp/openclaw-state/extensions/demo",
|
||||
version: "1.2.3",
|
||||
packageName: "demo",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "community",
|
||||
version: "1.2.3",
|
||||
integrity: "sha256-abc",
|
||||
resolvedAt: "2026-03-22T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(installedCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: installedCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "demo"]);
|
||||
|
||||
expect(installPluginFromClawHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:demo",
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
});
|
||||
|
||||
it("falls back to npm when ClawHub does not have the package", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
|
||||
code: "package_not_found",
|
||||
});
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: "/tmp/openclaw-state/extensions/demo",
|
||||
version: "1.2.3",
|
||||
npmResolution: {
|
||||
packageName: "demo",
|
||||
resolvedVersion: "1.2.3",
|
||||
tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz",
|
||||
},
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(enabledCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "demo"]);
|
||||
|
||||
expect(installPluginFromClawHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:demo",
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "demo",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back to npm when ClawHub rejects a real package", async () => {
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'Use "openclaw skills install demo" instead.',
|
||||
code: "skill_package",
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "install", "demo"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.');
|
||||
});
|
||||
|
||||
it("falls back to installing hook packs from npm specs", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"demo-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/demo-hooks@1.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found",
|
||||
code: "package_not_found",
|
||||
});
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.plugin.json",
|
||||
});
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
hookPackId: "demo-hooks",
|
||||
hooks: ["command-audit"],
|
||||
targetDir: "/tmp/hooks/demo-hooks",
|
||||
version: "1.2.3",
|
||||
npmResolution: {
|
||||
name: "@acme/demo-hooks",
|
||||
spec: "@acme/demo-hooks@1.2.3",
|
||||
integrity: "sha256-demo",
|
||||
},
|
||||
});
|
||||
recordHookInstall.mockReturnValue(installedCfg);
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]);
|
||||
|
||||
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "@acme/demo-hooks",
|
||||
}),
|
||||
);
|
||||
expect(recordHookInstall).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
hookId: "demo-hooks",
|
||||
hooks: ["command-audit"],
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,945 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
|
||||
|
||||
const loadConfig = vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig);
|
||||
const writeConfigFile = vi.fn<(config: OpenClawConfig) => Promise<void>>(async () => undefined);
|
||||
const resolveStateDir = vi.fn(() => "/tmp/openclaw-state");
|
||||
const installPluginFromMarketplace = vi.fn();
|
||||
const listMarketplacePlugins = vi.fn();
|
||||
const resolveMarketplaceInstallShortcut = vi.fn();
|
||||
const enablePluginInConfig = vi.fn();
|
||||
const recordPluginInstall = vi.fn();
|
||||
const clearPluginManifestRegistryCache = vi.fn();
|
||||
const buildPluginStatusReport = vi.fn();
|
||||
const applyExclusiveSlotSelection = vi.fn();
|
||||
const uninstallPlugin = vi.fn();
|
||||
const updateNpmInstalledPlugins = vi.fn();
|
||||
const updateNpmInstalledHookPacks = vi.fn();
|
||||
const promptYesNo = vi.fn();
|
||||
const installPluginFromNpmSpec = vi.fn();
|
||||
const installPluginFromPath = vi.fn();
|
||||
const installPluginFromClawHub = vi.fn();
|
||||
const parseClawHubPluginSpec = vi.fn();
|
||||
const installHooksFromNpmSpec = vi.fn();
|
||||
const installHooksFromPath = vi.fn();
|
||||
const recordHookInstall = vi.fn();
|
||||
|
||||
const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } =
|
||||
createCliRuntimeCapture();
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => loadConfig(),
|
||||
writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config),
|
||||
}));
|
||||
|
||||
vi.mock("../config/paths.js", () => ({
|
||||
resolveStateDir: () => resolveStateDir(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/marketplace.js", () => ({
|
||||
installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplace(...args),
|
||||
listMarketplacePlugins: (...args: unknown[]) => listMarketplacePlugins(...args),
|
||||
resolveMarketplaceInstallShortcut: (...args: unknown[]) =>
|
||||
resolveMarketplaceInstallShortcut(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/enable.js", () => ({
|
||||
enablePluginInConfig: (...args: unknown[]) => enablePluginInConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/installs.js", () => ({
|
||||
recordPluginInstall: (...args: unknown[]) => recordPluginInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", () => ({
|
||||
clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/status.js", () => ({
|
||||
buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/slots.js", () => ({
|
||||
applyExclusiveSlotSelection: (...args: unknown[]) => applyExclusiveSlotSelection(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/uninstall.js", () => ({
|
||||
uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args),
|
||||
resolveUninstallDirectoryTarget: ({
|
||||
installRecord,
|
||||
}: {
|
||||
installRecord?: { installPath?: string; sourcePath?: string };
|
||||
}) => installRecord?.installPath ?? installRecord?.sourcePath ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/update.js", () => ({
|
||||
updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/update.js", () => ({
|
||||
updateNpmInstalledHookPacks: (...args: unknown[]) => updateNpmInstalledHookPacks(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./prompt.js", () => ({
|
||||
promptYesNo: (...args: unknown[]) => promptYesNo(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/install.js", () => ({
|
||||
PLUGIN_INSTALL_ERROR_CODE: {
|
||||
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
|
||||
},
|
||||
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args),
|
||||
installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/install.js", () => ({
|
||||
installHooksFromNpmSpec: (...args: unknown[]) => installHooksFromNpmSpec(...args),
|
||||
installHooksFromPath: (...args: unknown[]) => installHooksFromPath(...args),
|
||||
resolveHookInstallDir: (hookId: string) => `/tmp/hooks/${hookId}`,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/installs.js", () => ({
|
||||
recordHookInstall: (...args: unknown[]) => recordHookInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/clawhub.js", () => ({
|
||||
CLAWHUB_INSTALL_ERROR_CODE: {
|
||||
PACKAGE_NOT_FOUND: "package_not_found",
|
||||
VERSION_NOT_FOUND: "version_not_found",
|
||||
},
|
||||
installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHub(...args),
|
||||
formatClawHubSpecifier: ({ name, version }: { name: string; version?: string }) =>
|
||||
`clawhub:${name}${version ? `@${version}` : ""}`,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/clawhub.js", () => ({
|
||||
parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpec(...args),
|
||||
}));
|
||||
|
||||
const { registerPluginsCli } = await import("./plugins-cli.js");
|
||||
|
||||
describe("plugins cli", () => {
|
||||
const createProgram = () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerPluginsCli(program);
|
||||
return program;
|
||||
};
|
||||
|
||||
const runCommand = (argv: string[]) => createProgram().parseAsync(argv, { from: "user" });
|
||||
|
||||
beforeEach(() => {
|
||||
resetRuntimeCapture();
|
||||
loadConfig.mockReset();
|
||||
writeConfigFile.mockReset();
|
||||
resolveStateDir.mockReset();
|
||||
installPluginFromMarketplace.mockReset();
|
||||
listMarketplacePlugins.mockReset();
|
||||
resolveMarketplaceInstallShortcut.mockReset();
|
||||
enablePluginInConfig.mockReset();
|
||||
recordPluginInstall.mockReset();
|
||||
clearPluginManifestRegistryCache.mockReset();
|
||||
buildPluginStatusReport.mockReset();
|
||||
applyExclusiveSlotSelection.mockReset();
|
||||
uninstallPlugin.mockReset();
|
||||
updateNpmInstalledPlugins.mockReset();
|
||||
promptYesNo.mockReset();
|
||||
updateNpmInstalledHookPacks.mockReset();
|
||||
installPluginFromNpmSpec.mockReset();
|
||||
installPluginFromPath.mockReset();
|
||||
installPluginFromClawHub.mockReset();
|
||||
parseClawHubPluginSpec.mockReset();
|
||||
installHooksFromNpmSpec.mockReset();
|
||||
installHooksFromPath.mockReset();
|
||||
recordHookInstall.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({} as OpenClawConfig);
|
||||
writeConfigFile.mockResolvedValue(undefined);
|
||||
resolveStateDir.mockReturnValue("/tmp/openclaw-state");
|
||||
resolveMarketplaceInstallShortcut.mockResolvedValue(null);
|
||||
installPluginFromMarketplace.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "marketplace install failed",
|
||||
});
|
||||
enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg }));
|
||||
recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({
|
||||
config,
|
||||
warnings: [],
|
||||
}));
|
||||
uninstallPlugin.mockResolvedValue({
|
||||
ok: true,
|
||||
config: {} as OpenClawConfig,
|
||||
warnings: [],
|
||||
actions: {
|
||||
entry: false,
|
||||
install: false,
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
directory: false,
|
||||
},
|
||||
});
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: {} as OpenClawConfig,
|
||||
});
|
||||
updateNpmInstalledHookPacks.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: {} as OpenClawConfig,
|
||||
});
|
||||
promptYesNo.mockResolvedValue(true);
|
||||
installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" });
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "npm install disabled in test",
|
||||
});
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "clawhub install disabled in test",
|
||||
});
|
||||
parseClawHubPluginSpec.mockReturnValue(null);
|
||||
installHooksFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "hook path install disabled in test",
|
||||
});
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "hook npm install disabled in test",
|
||||
});
|
||||
recordHookInstall.mockImplementation((cfg: OpenClawConfig) => cfg);
|
||||
});
|
||||
|
||||
it("exits when --marketplace is combined with --link", async () => {
|
||||
await expect(
|
||||
runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain("`--link` is not supported with `--marketplace`.");
|
||||
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits when marketplace install fails", async () => {
|
||||
await expect(
|
||||
runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installPluginFromMarketplace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
marketplace: "local/repo",
|
||||
plugin: "alpha",
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs marketplace plugins and persists config", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
...enabledCfg,
|
||||
plugins: {
|
||||
...enabledCfg.plugins,
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "marketplace",
|
||||
installPath: "/tmp/openclaw-state/extensions/alpha",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromMarketplace.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "alpha",
|
||||
targetDir: "/tmp/openclaw-state/extensions/alpha",
|
||||
version: "1.2.3",
|
||||
marketplaceName: "Claude",
|
||||
marketplaceSource: "local/repo",
|
||||
marketplacePlugin: "alpha",
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(installedCfg);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", kind: "provider" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: installedCfg,
|
||||
warnings: ["slot adjusted"],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]);
|
||||
|
||||
expect(clearPluginManifestRegistryCache).toHaveBeenCalledTimes(1);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true);
|
||||
expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true);
|
||||
});
|
||||
|
||||
it("installs ClawHub plugins and persists source metadata", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
...enabledCfg,
|
||||
plugins: {
|
||||
...enabledCfg.plugins,
|
||||
installs: {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo@1.2.3",
|
||||
installPath: "/tmp/openclaw-state/extensions/demo",
|
||||
clawhubPackage: "demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: "/tmp/openclaw-state/extensions/demo",
|
||||
version: "1.2.3",
|
||||
packageName: "demo",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
version: "1.2.3",
|
||||
integrity: "sha256-abc",
|
||||
resolvedAt: "2026-03-22T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(installedCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: installedCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "install", "clawhub:demo"]);
|
||||
|
||||
expect(installPluginFromClawHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:demo",
|
||||
}),
|
||||
);
|
||||
expect(recordPluginInstall).toHaveBeenCalledWith(
|
||||
enabledCfg,
|
||||
expect.objectContaining({
|
||||
pluginId: "demo",
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo@1.2.3",
|
||||
clawhubPackage: "demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "official",
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
expect(runtimeLogs.some((line) => line.includes("Installed plugin: demo"))).toBe(true);
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers ClawHub before npm for bare plugin specs", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
...enabledCfg,
|
||||
plugins: {
|
||||
...enabledCfg.plugins,
|
||||
installs: {
|
||||
demo: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo@1.2.3",
|
||||
installPath: "/tmp/openclaw-state/extensions/demo",
|
||||
clawhubPackage: "demo",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: "/tmp/openclaw-state/extensions/demo",
|
||||
version: "1.2.3",
|
||||
packageName: "demo",
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: "demo",
|
||||
clawhubFamily: "code-plugin",
|
||||
clawhubChannel: "community",
|
||||
version: "1.2.3",
|
||||
integrity: "sha256-abc",
|
||||
resolvedAt: "2026-03-22T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(installedCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: installedCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "install", "demo"]);
|
||||
|
||||
expect(installPluginFromClawHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:demo",
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
});
|
||||
|
||||
it("falls back to npm when ClawHub does not have the package", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
|
||||
code: "package_not_found",
|
||||
});
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: "/tmp/openclaw-state/extensions/demo",
|
||||
version: "1.2.3",
|
||||
npmResolution: {
|
||||
packageName: "demo",
|
||||
resolvedVersion: "1.2.3",
|
||||
tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz",
|
||||
},
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(enabledCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "install", "demo"]);
|
||||
|
||||
expect(installPluginFromClawHub).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:demo",
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "demo",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back to npm when ClawHub rejects a real package", async () => {
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'Use "openclaw skills install demo" instead.',
|
||||
code: "skill_package",
|
||||
});
|
||||
|
||||
await expect(runCommand(["plugins", "install", "demo"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.');
|
||||
});
|
||||
it("falls back to installing hook packs from npm specs", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"demo-hooks": {
|
||||
source: "npm",
|
||||
spec: "@acme/demo-hooks@1.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found",
|
||||
code: "package_not_found",
|
||||
});
|
||||
installPluginFromNpmSpec.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.plugin.json",
|
||||
});
|
||||
installHooksFromNpmSpec.mockResolvedValue({
|
||||
ok: true,
|
||||
hookPackId: "demo-hooks",
|
||||
hooks: ["command-audit"],
|
||||
targetDir: "/tmp/hooks/demo-hooks",
|
||||
version: "1.2.3",
|
||||
npmResolution: {
|
||||
name: "@acme/demo-hooks",
|
||||
spec: "@acme/demo-hooks@1.2.3",
|
||||
integrity: "sha256-demo",
|
||||
},
|
||||
});
|
||||
recordHookInstall.mockReturnValue(installedCfg);
|
||||
|
||||
await runCommand(["plugins", "install", "@acme/demo-hooks"]);
|
||||
|
||||
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "@acme/demo-hooks",
|
||||
}),
|
||||
);
|
||||
expect(recordHookInstall).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
hookId: "demo-hooks",
|
||||
hooks: ["command-audit"],
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
|
||||
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
|
||||
});
|
||||
|
||||
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 runCommand(["plugins", "update", "demo-hooks"]);
|
||||
|
||||
expect(updateNpmInstalledHookPacks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
hookIds: ["demo-hooks"],
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
|
||||
expect(
|
||||
runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")),
|
||||
).toBe(true);
|
||||
});
|
||||
it("shows uninstall dry-run preview without mutating config", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-state/extensions/alpha",
|
||||
installPath: "/tmp/openclaw-state/extensions/alpha",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "uninstall", "alpha", "--dry-run"]);
|
||||
|
||||
expect(uninstallPlugin).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true);
|
||||
});
|
||||
|
||||
it("uninstalls with --force and --keep-files without prompting", async () => {
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-state/extensions/alpha",
|
||||
installPath: "/tmp/openclaw-state/extensions/alpha",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const nextConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
installs: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(baseConfig);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
uninstallPlugin.mockResolvedValue({
|
||||
ok: true,
|
||||
config: nextConfig,
|
||||
warnings: [],
|
||||
actions: {
|
||||
entry: true,
|
||||
install: true,
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
directory: false,
|
||||
},
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]);
|
||||
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
expect(uninstallPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginId: "alpha",
|
||||
deleteFiles: false,
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
|
||||
});
|
||||
|
||||
it("exits when uninstall target is not managed by plugin install records", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {},
|
||||
installs: {},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await expect(runCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records");
|
||||
expect(uninstallPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits when update is called without id and without --all", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
installs: {},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
await expect(runCommand(["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 runCommand(["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("maps an explicit unscoped npm dist-tag update to the tracked plugin id", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"openclaw-codex-app-server": {
|
||||
source: "npm",
|
||||
spec: "openclaw-codex-app-server",
|
||||
installPath: "/tmp/openclaw-codex-app-server",
|
||||
resolvedName: "openclaw-codex-app-server",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "update", "openclaw-codex-app-server@beta"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
pluginIds: ["openclaw-codex-app-server"],
|
||||
specOverrides: {
|
||||
"openclaw-codex-app-server": "openclaw-codex-app-server@beta",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps an explicit scoped npm dist-tag update to the tracked plugin id", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
installPath: "/tmp/voice-call",
|
||||
resolvedName: "@openclaw/voice-call",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "update", "@openclaw/voice-call@beta"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
pluginIds: ["voice-call"],
|
||||
specOverrides: {
|
||||
"voice-call": "@openclaw/voice-call@beta",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps an explicit npm version update to the tracked plugin id", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"openclaw-codex-app-server": {
|
||||
source: "npm",
|
||||
spec: "openclaw-codex-app-server",
|
||||
installPath: "/tmp/openclaw-codex-app-server",
|
||||
resolvedName: "openclaw-codex-app-server",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "update", "openclaw-codex-app-server@0.2.0-beta.4"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
pluginIds: ["openclaw-codex-app-server"],
|
||||
specOverrides: {
|
||||
"openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps using the recorded npm tag when update is invoked by plugin id", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"openclaw-codex-app-server": {
|
||||
source: "npm",
|
||||
spec: "openclaw-codex-app-server@beta",
|
||||
installPath: "/tmp/openclaw-codex-app-server",
|
||||
resolvedName: "openclaw-codex-app-server",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "update", "openclaw-codex-app-server"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
pluginIds: ["openclaw-codex-app-server"],
|
||||
}),
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
specOverrides: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }],
|
||||
changed: true,
|
||||
config: nextConfig,
|
||||
});
|
||||
updateNpmInstalledHookPacks.mockResolvedValue({
|
||||
outcomes: [],
|
||||
changed: false,
|
||||
config: nextConfig,
|
||||
});
|
||||
|
||||
await runCommand(["plugins", "update", "alpha"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
pluginIds: ["alpha"],
|
||||
dryRun: false,
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
|
||||
expect(
|
||||
runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
121
src/cli/plugins-cli.uninstall.test.ts
Normal file
121
src/cli/plugins-cli.uninstall.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
buildPluginStatusReport,
|
||||
loadConfig,
|
||||
promptYesNo,
|
||||
resetPluginsCliTestState,
|
||||
runPluginsCommand,
|
||||
runtimeErrors,
|
||||
runtimeLogs,
|
||||
uninstallPlugin,
|
||||
writeConfigFile,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
describe("plugins cli uninstall", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginsCliTestState();
|
||||
});
|
||||
|
||||
it("shows uninstall dry-run preview without mutating config", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-state/extensions/alpha",
|
||||
installPath: "/tmp/openclaw-state/extensions/alpha",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "uninstall", "alpha", "--dry-run"]);
|
||||
|
||||
expect(uninstallPlugin).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true);
|
||||
});
|
||||
|
||||
it("uninstalls with --force and --keep-files without prompting", async () => {
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
alpha: { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
alpha: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-state/extensions/alpha",
|
||||
installPath: "/tmp/openclaw-state/extensions/alpha",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const nextConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
installs: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(baseConfig);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
uninstallPlugin.mockResolvedValue({
|
||||
ok: true,
|
||||
config: nextConfig,
|
||||
warnings: [],
|
||||
actions: {
|
||||
entry: true,
|
||||
install: true,
|
||||
allowlist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
directory: false,
|
||||
},
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]);
|
||||
|
||||
expect(promptYesNo).not.toHaveBeenCalled();
|
||||
expect(uninstallPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginId: "alpha",
|
||||
deleteFiles: false,
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
|
||||
});
|
||||
|
||||
it("exits when uninstall target is not managed by plugin install records", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {},
|
||||
installs: {},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
buildPluginStatusReport.mockReturnValue({
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records");
|
||||
expect(uninstallPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
288
src/cli/plugins-cli.update.test.ts
Normal file
288
src/cli/plugins-cli.update.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
loadConfig,
|
||||
resetPluginsCliTestState,
|
||||
runPluginsCommand,
|
||||
runtimeErrors,
|
||||
runtimeLogs,
|
||||
updateNpmInstalledHookPacks,
|
||||
updateNpmInstalledPlugins,
|
||||
writeConfigFile,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
describe("plugins cli update", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginsCliTestState();
|
||||
});
|
||||
|
||||
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"]);
|
||||
|
||||
expect(updateNpmInstalledHookPacks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
hookIds: ["demo-hooks"],
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
|
||||
expect(
|
||||
runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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("maps an explicit unscoped npm dist-tag update to the tracked plugin id", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"openclaw-codex-app-server": {
|
||||
source: "npm",
|
||||
spec: "openclaw-codex-app-server",
|
||||
installPath: "/tmp/openclaw-codex-app-server",
|
||||
resolvedName: "openclaw-codex-app-server",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "openclaw-codex-app-server@beta"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
pluginIds: ["openclaw-codex-app-server"],
|
||||
specOverrides: {
|
||||
"openclaw-codex-app-server": "openclaw-codex-app-server@beta",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps an explicit scoped npm dist-tag update to the tracked plugin id", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
installPath: "/tmp/voice-call",
|
||||
resolvedName: "@openclaw/voice-call",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "@openclaw/voice-call@beta"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
pluginIds: ["voice-call"],
|
||||
specOverrides: {
|
||||
"voice-call": "@openclaw/voice-call@beta",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps an explicit npm version update to the tracked plugin id", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"openclaw-codex-app-server": {
|
||||
source: "npm",
|
||||
spec: "openclaw-codex-app-server",
|
||||
installPath: "/tmp/openclaw-codex-app-server",
|
||||
resolvedName: "openclaw-codex-app-server",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "openclaw-codex-app-server@0.2.0-beta.4"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
pluginIds: ["openclaw-codex-app-server"],
|
||||
specOverrides: {
|
||||
"openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps using the recorded npm tag when update is invoked by plugin id", async () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"openclaw-codex-app-server": {
|
||||
source: "npm",
|
||||
spec: "openclaw-codex-app-server@beta",
|
||||
installPath: "/tmp/openclaw-codex-app-server",
|
||||
resolvedName: "openclaw-codex-app-server",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(config);
|
||||
updateNpmInstalledPlugins.mockResolvedValue({
|
||||
config,
|
||||
changed: false,
|
||||
outcomes: [],
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "openclaw-codex-app-server"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
pluginIds: ["openclaw-codex-app-server"],
|
||||
}),
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
specOverrides: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
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"]);
|
||||
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
pluginIds: ["alpha"],
|
||||
dryRun: false,
|
||||
}),
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
|
||||
expect(
|
||||
runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user