mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 10:50:58 +00:00
419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import {
|
|
applyExclusiveSlotSelection,
|
|
buildPluginStatusReport,
|
|
clearPluginManifestRegistryCache,
|
|
enablePluginInConfig,
|
|
installHooksFromNpmSpec,
|
|
installPluginFromClawHub,
|
|
installPluginFromMarketplace,
|
|
installPluginFromNpmSpec,
|
|
loadConfig,
|
|
readConfigFileSnapshot,
|
|
parseClawHubPluginSpec,
|
|
recordHookInstall,
|
|
recordPluginInstall,
|
|
resetPluginsCliTestState,
|
|
runPluginsCommand,
|
|
runtimeErrors,
|
|
runtimeLogs,
|
|
writeConfigFile,
|
|
} from "./plugins-cli-test-helpers.js";
|
|
|
|
function createEnabledPluginConfig(pluginId: string): OpenClawConfig {
|
|
return {
|
|
plugins: {
|
|
entries: {
|
|
[pluginId]: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function createClawHubInstalledConfig(params: {
|
|
pluginId: string;
|
|
install: Record<string, unknown>;
|
|
}): OpenClawConfig {
|
|
const enabledCfg = createEnabledPluginConfig(params.pluginId);
|
|
return {
|
|
...enabledCfg,
|
|
plugins: {
|
|
...enabledCfg.plugins,
|
|
installs: {
|
|
[params.pluginId]: params.install,
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function createClawHubInstallResult(params: {
|
|
pluginId: string;
|
|
packageName: string;
|
|
version: string;
|
|
channel: string;
|
|
}): Awaited<ReturnType<typeof installPluginFromClawHub>> {
|
|
return {
|
|
ok: true,
|
|
pluginId: params.pluginId,
|
|
targetDir: `/tmp/openclaw-state/extensions/${params.pluginId}`,
|
|
version: params.version,
|
|
packageName: params.packageName,
|
|
clawhub: {
|
|
source: "clawhub",
|
|
clawhubUrl: "https://clawhub.ai",
|
|
clawhubPackage: params.packageName,
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: params.channel,
|
|
version: params.version,
|
|
integrity: "sha256-abc",
|
|
resolvedAt: "2026-03-22T00:00:00.000Z",
|
|
},
|
|
};
|
|
}
|
|
|
|
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("fails closed for unrelated invalid config before installer side effects", async () => {
|
|
const invalidConfigErr = new Error("config invalid");
|
|
(invalidConfigErr as { code?: string }).code = "INVALID_CONFIG";
|
|
loadConfig.mockImplementation(() => {
|
|
throw invalidConfigErr;
|
|
});
|
|
readConfigFileSnapshot.mockResolvedValue({
|
|
path: "/tmp/openclaw-config.json5",
|
|
exists: true,
|
|
raw: '{ "models": { "default": 123 } }',
|
|
parsed: { models: { default: 123 } },
|
|
resolved: { models: { default: 123 } },
|
|
valid: false,
|
|
config: { models: { default: 123 } },
|
|
hash: "mock",
|
|
issues: [{ path: "models.default", message: "invalid model ref" }],
|
|
warnings: [],
|
|
legacyIssues: [],
|
|
});
|
|
|
|
await expect(runPluginsCommand(["plugins", "install", "alpha"])).rejects.toThrow("__exit__:1");
|
|
|
|
expect(runtimeErrors.at(-1)).toContain(
|
|
"Config invalid; run `openclaw doctor --fix` before installing plugins.",
|
|
);
|
|
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
|
|
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
|
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 = createEnabledPluginConfig("demo");
|
|
const installedCfg = createClawHubInstalledConfig({
|
|
pluginId: "demo",
|
|
install: {
|
|
source: "clawhub",
|
|
spec: "clawhub:demo@1.2.3",
|
|
installPath: "/tmp/openclaw-state/extensions/demo",
|
|
clawhubPackage: "demo",
|
|
clawhubFamily: "code-plugin",
|
|
clawhubChannel: "official",
|
|
},
|
|
});
|
|
|
|
loadConfig.mockReturnValue(cfg);
|
|
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
|
|
installPluginFromClawHub.mockResolvedValue(
|
|
createClawHubInstallResult({
|
|
pluginId: "demo",
|
|
packageName: "demo",
|
|
version: "1.2.3",
|
|
channel: "official",
|
|
}),
|
|
);
|
|
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 = createEnabledPluginConfig("demo");
|
|
const installedCfg = createClawHubInstalledConfig({
|
|
pluginId: "demo",
|
|
install: {
|
|
source: "clawhub",
|
|
spec: "clawhub:demo@1.2.3",
|
|
installPath: "/tmp/openclaw-state/extensions/demo",
|
|
clawhubPackage: "demo",
|
|
},
|
|
});
|
|
|
|
loadConfig.mockReturnValue(cfg);
|
|
installPluginFromClawHub.mockResolvedValue(
|
|
createClawHubInstallResult({
|
|
pluginId: "demo",
|
|
packageName: "demo",
|
|
version: "1.2.3",
|
|
channel: "community",
|
|
}),
|
|
);
|
|
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);
|
|
});
|
|
});
|