mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 19:21:35 +00:00
268 lines
8.9 KiB
TypeScript
268 lines
8.9 KiB
TypeScript
// Plugin install plan tests cover install planning for local, registry, and bundled plugins.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { PLUGIN_INSTALL_ERROR_CODE } from "../plugins/install.js";
|
|
import {
|
|
resolveCatalogOfficialExternalInstallPlan,
|
|
resolveCatalogOfficialExternalNpmPackageTrust,
|
|
} from "../plugins/official-external-install-trust.js";
|
|
import {
|
|
resolveBundledInstallPlanForCatalogEntry,
|
|
resolveBundledInstallPlanForNpmFailure,
|
|
resolvePluginInstallSourcePlan,
|
|
} from "./plugin-install-plan.js";
|
|
|
|
function createSourceCheckoutPlugin(pluginId: string): {
|
|
packageRoot: string;
|
|
pluginRoot: string;
|
|
} {
|
|
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-plan-"));
|
|
fs.mkdirSync(path.join(packageRoot, ".git"));
|
|
fs.mkdirSync(path.join(packageRoot, "src"));
|
|
fs.mkdirSync(path.join(packageRoot, "extensions"));
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", pluginId);
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
|
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n");
|
|
return { packageRoot, pluginRoot };
|
|
}
|
|
|
|
describe("plugin install plan helpers", () => {
|
|
it.each([
|
|
"clawhub:",
|
|
"clawhub:demo@",
|
|
"clawhub:@scope/pkg@",
|
|
"CLAWHUB:",
|
|
"ClAwHuB:demo@",
|
|
" clawhub:demo@ ",
|
|
])("rejects the malformed explicit ClawHub selector %s before npm fallback", (raw) => {
|
|
expect(resolvePluginInstallSourcePlan({ raw, mode: "install" })).toEqual({
|
|
ok: false,
|
|
error: `Unsupported ClawHub plugin spec: ${raw}`,
|
|
});
|
|
});
|
|
|
|
it.each(["clawhub:demo", "CLAWHUB:demo", "clawhub:@scope/pkg@1.2.3"])(
|
|
"keeps the valid explicit ClawHub selector %s on the ClawHub install path",
|
|
(raw) => {
|
|
expect(resolvePluginInstallSourcePlan({ raw, mode: "install" })).toMatchObject({
|
|
ok: true,
|
|
request: { source: "clawhub", spec: raw },
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps an existing ClawHub-prefixed local path on the local install path",
|
|
() => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-plan-clawhub-"));
|
|
const localPath = path.join(tempRoot, "clawhub:demo@");
|
|
fs.mkdirSync(localPath);
|
|
|
|
try {
|
|
expect(resolvePluginInstallSourcePlan({ raw: localPath, mode: "install" })).toMatchObject({
|
|
ok: true,
|
|
request: {
|
|
source: "local",
|
|
path: localPath,
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("keeps explicit npm specs with local-looking suffixes on the registry path", () => {
|
|
expect(resolvePluginInstallSourcePlan({ raw: "npm:plugin.js", mode: "install" })).toMatchObject(
|
|
{
|
|
ok: true,
|
|
request: { source: "npm", spec: "plugin.js" },
|
|
},
|
|
);
|
|
});
|
|
|
|
it("resolves exact official external plugin ids before npm fallback", () => {
|
|
const result = resolveCatalogOfficialExternalInstallPlan("wecom-openclaw-plugin");
|
|
|
|
expect(result).toEqual({
|
|
pluginId: "wecom-openclaw-plugin",
|
|
npmSpec: "@wecom/wecom-openclaw-plugin@2026.5.7",
|
|
expectedIntegrity:
|
|
"sha512-TCkP9as00WfEhgFWG8YL/rcmaWGIshAki2HQh83nTRccGfVBCoGjrEboTTqq3yDmK9koWTV11zi8u8A4dNtvug==",
|
|
});
|
|
});
|
|
|
|
it("skips official external plan for explicit npm selectors", () => {
|
|
expect(resolveCatalogOfficialExternalInstallPlan("wecom-openclaw-plugin@beta")).toBeNull();
|
|
expect(
|
|
resolveCatalogOfficialExternalInstallPlan("@wecom/wecom-openclaw-plugin@2026.5.7"),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("trusts exact official external npm packages without remapping the spec", () => {
|
|
const result = resolveCatalogOfficialExternalNpmPackageTrust(
|
|
"@wecom/wecom-openclaw-plugin@2026.5.7",
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
pluginId: "wecom-openclaw-plugin",
|
|
expectedIntegrity:
|
|
"sha512-TCkP9as00WfEhgFWG8YL/rcmaWGIshAki2HQh83nTRccGfVBCoGjrEboTTqq3yDmK9koWTV11zi8u8A4dNtvug==",
|
|
trustedSourceLinkedOfficialInstall: true,
|
|
});
|
|
});
|
|
|
|
it("does not trust npm package names outside the official external catalog", () => {
|
|
const result = resolveCatalogOfficialExternalNpmPackageTrust("@acme/outside@1.0.0");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("prefers bundled catalog plugin by id before npm spec", () => {
|
|
const findBundledSource = vi
|
|
.fn()
|
|
.mockImplementation(({ kind, value }: { kind: "pluginId" | "npmSpec"; value: string }) => {
|
|
if (kind === "pluginId" && value === "voice-call") {
|
|
return {
|
|
pluginId: "voice-call",
|
|
localPath: installedPluginRoot("/tmp", "voice-call"),
|
|
npmSpec: "@openclaw/voice-call",
|
|
};
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const result = resolveBundledInstallPlanForCatalogEntry({
|
|
pluginId: "voice-call",
|
|
npmSpec: "@openclaw/voice-call",
|
|
findBundledSource,
|
|
});
|
|
|
|
expect(findBundledSource).toHaveBeenCalledWith({ kind: "pluginId", value: "voice-call" });
|
|
expect(result?.bundledSource.localPath).toBe(installedPluginRoot("/tmp", "voice-call"));
|
|
});
|
|
|
|
it("rejects npm-spec matches that resolve to a different plugin id", () => {
|
|
const findBundledSource = vi
|
|
.fn()
|
|
.mockImplementation(({ kind }: { kind: "pluginId" | "npmSpec"; value: string }) => {
|
|
if (kind === "npmSpec") {
|
|
return {
|
|
pluginId: "not-voice-call",
|
|
localPath: installedPluginRoot("/tmp", "not-voice-call"),
|
|
npmSpec: "@openclaw/voice-call",
|
|
};
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const result = resolveBundledInstallPlanForCatalogEntry({
|
|
pluginId: "voice-call",
|
|
npmSpec: "@openclaw/voice-call",
|
|
findBundledSource,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects plugin-id bundled matches when the catalog npm spec was overridden", () => {
|
|
const findBundledSource = vi
|
|
.fn()
|
|
.mockImplementation(({ kind }: { kind: "pluginId" | "npmSpec"; value: string }) => {
|
|
if (kind === "pluginId") {
|
|
return {
|
|
pluginId: "whatsapp",
|
|
localPath: installedPluginRoot("/tmp", "whatsapp"),
|
|
npmSpec: "@openclaw/whatsapp",
|
|
};
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const result = resolveBundledInstallPlanForCatalogEntry({
|
|
pluginId: "whatsapp",
|
|
npmSpec: "@vendor/whatsapp-fork",
|
|
findBundledSource,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("uses npm-spec bundled fallback only for package-not-found", () => {
|
|
const findBundledSource = vi.fn().mockReturnValue({
|
|
pluginId: "voice-call",
|
|
localPath: installedPluginRoot("/tmp", "voice-call"),
|
|
npmSpec: "@openclaw/voice-call",
|
|
});
|
|
const result = resolveBundledInstallPlanForNpmFailure({
|
|
rawSpec: "@openclaw/voice-call",
|
|
code: PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND,
|
|
findBundledSource,
|
|
});
|
|
|
|
expect(findBundledSource).toHaveBeenCalledWith({
|
|
kind: "npmSpec",
|
|
value: "@openclaw/voice-call",
|
|
});
|
|
expect(result?.warning).toContain("npm package unavailable");
|
|
});
|
|
|
|
it("does not fall back to source checkout bundles after npm package-not-found", () => {
|
|
const { packageRoot, pluginRoot } = createSourceCheckoutPlugin("codex");
|
|
try {
|
|
const findBundledSource = vi.fn().mockReturnValue({
|
|
pluginId: "codex",
|
|
localPath: pluginRoot,
|
|
npmSpec: "@openclaw/codex",
|
|
});
|
|
|
|
const result = resolveBundledInstallPlanForNpmFailure({
|
|
rawSpec: "@openclaw/codex",
|
|
code: PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND,
|
|
findBundledSource,
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
} finally {
|
|
fs.rmSync(packageRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("allows bare plugin ids to fall back to source checkout bundles", () => {
|
|
const { packageRoot, pluginRoot } = createSourceCheckoutPlugin("codex");
|
|
try {
|
|
const findBundledSource = vi.fn().mockReturnValue({
|
|
pluginId: "codex",
|
|
localPath: pluginRoot,
|
|
npmSpec: "@openclaw/codex",
|
|
});
|
|
|
|
const result = resolveBundledInstallPlanForNpmFailure({
|
|
rawSpec: "codex",
|
|
code: PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND,
|
|
findBundledSource,
|
|
});
|
|
|
|
expect(result?.bundledSource.pluginId).toBe("codex");
|
|
} finally {
|
|
fs.rmSync(packageRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips fallback for non-not-found npm failures", () => {
|
|
const findBundledSource = vi.fn();
|
|
const result = resolveBundledInstallPlanForNpmFailure({
|
|
rawSpec: "@openclaw/voice-call",
|
|
code: "INSTALL_FAILED",
|
|
findBundledSource,
|
|
});
|
|
|
|
expect(findBundledSource).not.toHaveBeenCalled();
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|