Files
openclaw/src/plugins/plugin-version-drift.test.ts
ooiuuii 2ea0e8807a fix(cli): show working commands for pinned plugin drift (#95541)
Merged via squash.

Prepared head SHA: d41b9b5b25
Co-authored-by: ooiuuii <169449607+ooiuuii@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 12:32:22 +08:00

390 lines
12 KiB
TypeScript

/** Tests plugin version drift detection between package, manifest, and install records. */
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
detectPluginVersionDrift,
resolvePluginVersionDriftUpdateCommand,
} from "./plugin-version-drift.js";
function npmRecord(
version: string,
overrides: Partial<PluginInstallRecord> = {},
): PluginInstallRecord {
const resolvedName = overrides.resolvedName ?? "@openclaw/whatsapp";
return {
source: "npm",
spec: `${resolvedName}@latest`,
resolvedName,
resolvedVersion: version,
...overrides,
};
}
function clawhubRecord(
version: string,
overrides: Partial<PluginInstallRecord> = {},
): PluginInstallRecord {
return {
source: "clawhub",
spec: "clawhub:@openclaw/whatsapp",
clawhubPackage: "@openclaw/whatsapp",
resolvedVersion: version,
...overrides,
};
}
describe("detectPluginVersionDrift", () => {
it("returns empty drifts when all externalized plugins match the gateway", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: npmRecord("2026.5.4"),
discord: npmRecord("2026.5.4", { resolvedName: "@openclaw/discord" }),
},
});
expect(result.drifts).toEqual([]);
expect(result.gatewayVersion).toBe("2026.5.4");
});
it("reports plugins whose installed version does not match the gateway", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: npmRecord("2026.5.3", {
resolvedName: "@openclaw/whatsapp",
spec: "@openclaw/whatsapp@2026.5.3",
}),
discord: npmRecord("2026.5.4", { resolvedName: "@openclaw/discord" }),
},
});
expect(result.drifts).toHaveLength(1);
expect(result.drifts[0]).toEqual({
pluginId: "whatsapp",
installedVersion: "2026.5.3",
gatewayVersion: "2026.5.4",
source: "npm",
packageName: "@openclaw/whatsapp",
spec: "@openclaw/whatsapp@2026.5.3",
});
});
it("treats a build-qualifier suffix on either side as matching (2026.5.4-1 ≈ 2026.5.4)", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4-1",
installRecords: {
whatsapp: npmRecord("2026.5.4"),
// ...and the inverse direction
discord: npmRecord("2026.5.4-1", { resolvedName: "@openclaw/discord" }),
},
});
expect(result.drifts).toEqual([]);
});
it("includes ClawHub-installed plugins in the drift check", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: clawhubRecord("2026.5.3"),
},
});
expect(result.drifts).toHaveLength(1);
expect(result.drifts[0]?.source).toBe("clawhub");
});
it("includes official ClawHub installs whose catalog entry only declares npm install metadata", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
discord: clawhubRecord("2026.5.3", {
spec: "clawhub:@openclaw/discord",
clawhubPackage: "@openclaw/discord",
clawhubChannel: "official",
clawhubUrl: "https://clawhub.ai",
}),
},
});
expect(result.drifts.map((d) => d.pluginId)).toEqual(["discord"]);
});
it("ignores community npm installs without an official lockstep contract", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
community: npmRecord("1.2.3", {
resolvedName: "community-plugin",
spec: "community-plugin@1.2.3",
}),
},
});
expect(result.drifts).toEqual([]);
});
it("ignores community ClawHub installs without an official lockstep contract", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
community: clawhubRecord("1.2.3", {
spec: "clawhub:community-plugin@1.2.3",
clawhubPackage: "community-plugin",
}),
},
});
expect(result.drifts).toEqual([]);
});
it("ignores official catalog installs pinned to independent package versions", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
"openclaw-plugin-yuanbao": npmRecord("2.13.1", {
resolvedName: "openclaw-plugin-yuanbao",
spec: "openclaw-plugin-yuanbao@2.13.1",
}),
},
});
expect(result.drifts).toEqual([]);
});
it("ignores exact catalog pins even when the pin matches the gateway version", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.7",
installRecords: {
"wecom-openclaw-plugin": npmRecord("2026.5.6", {
resolvedName: "@wecom/wecom-openclaw-plugin",
spec: "@wecom/wecom-openclaw-plugin@2026.5.6",
}),
},
});
expect(result.drifts).toEqual([]);
});
it("ignores install sources that are not official external installs", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
// archive/path/git installs are local artifacts; they pin to whatever
// the operator chose and should not be flagged on a gateway version
// bump alone.
archive: {
source: "archive",
resolvedName: "@openclaw/whatsapp",
resolvedVersion: "2026.5.3",
spec: "@openclaw/whatsapp@archive",
},
local: {
source: "path",
resolvedName: "@openclaw/whatsapp",
resolvedVersion: "2026.5.3",
spec: "/tmp/local-plugin",
},
forked: {
source: "git",
resolvedName: "@openclaw/whatsapp",
resolvedVersion: "2026.5.3",
spec: "git+ssh://example/forked",
},
},
});
expect(result.drifts).toEqual([]);
});
it("falls back to the install record's `version` field when `resolvedVersion` is absent", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: {
source: "npm",
spec: "@openclaw/whatsapp@latest",
resolvedName: "@openclaw/whatsapp",
version: "2026.5.3",
},
},
});
expect(result.drifts).toHaveLength(1);
expect(result.drifts[0]?.installedVersion).toBe("2026.5.3");
});
it("skips plugins with no recorded version (cannot detect drift)", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: { source: "npm", spec: "@openclaw/whatsapp@latest" },
},
});
expect(result.drifts).toEqual([]);
});
it("skips plugins that are explicitly disabled in config", () => {
const config: OpenClawConfig = {
plugins: {
entries: {
whatsapp: { enabled: false },
discord: { enabled: true },
},
},
} as OpenClawConfig;
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: npmRecord("2026.5.3"),
discord: npmRecord("2026.5.3", { resolvedName: "@openclaw/discord" }),
},
config,
});
expect(result.drifts.map((d) => d.pluginId)).toEqual(["discord"]);
});
it("skips plugins disabled by the global plugin activation policy", () => {
const config: OpenClawConfig = {
plugins: {
enabled: false,
},
} as OpenClawConfig;
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: npmRecord("2026.5.3"),
},
config,
});
expect(result.drifts).toEqual([]);
});
it("skips plugins blocked by denylist or restrictive allowlist policy", () => {
const denied = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: npmRecord("2026.5.3"),
},
config: {
plugins: {
deny: ["whatsapp"],
},
} as OpenClawConfig,
});
const notAllowed = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: npmRecord("2026.5.3"),
},
config: {
plugins: {
allow: ["discord"],
},
} as OpenClawConfig,
});
expect(denied.drifts).toEqual([]);
expect(notAllowed.drifts).toEqual([]);
});
it("includes plugins with no entry in config (default-enabled)", () => {
const config: OpenClawConfig = { plugins: { entries: {} } } as OpenClawConfig;
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: npmRecord("2026.5.3"),
},
config,
});
expect(result.drifts).toHaveLength(1);
});
it("returns drifts sorted by pluginId for deterministic output", () => {
const result = detectPluginVersionDrift({
gatewayVersion: "2026.5.4",
installRecords: {
whatsapp: npmRecord("2026.5.3"),
discord: npmRecord("2026.5.3", { resolvedName: "@openclaw/discord" }),
matrix: npmRecord("2026.5.3", { resolvedName: "@openclaw/matrix" }),
},
});
expect(result.drifts.map((d) => d.pluginId)).toEqual(["discord", "matrix", "whatsapp"]);
});
});
describe("resolvePluginVersionDriftUpdateCommand", () => {
it("uses an exact npm package target when the drifted install is pinned", () => {
expect(
resolvePluginVersionDriftUpdateCommand({
pluginId: "brave",
installedVersion: "2026.6.9",
gatewayVersion: "2026.6.10-beta.1",
source: "npm",
packageName: "@openclaw/brave-plugin",
spec: "@openclaw/brave-plugin@2026.6.9",
}),
).toBe("openclaw plugins update @openclaw/brave-plugin@2026.6.10-beta.1");
});
it("parses the package name from exact npm specs when drift metadata is sparse", () => {
expect(
resolvePluginVersionDriftUpdateCommand({
pluginId: "brave",
installedVersion: "2026.6.9",
gatewayVersion: "2026.6.10-beta.1",
source: "npm",
spec: "@openclaw/brave-plugin@2026.6.9",
}),
).toBe("openclaw plugins update @openclaw/brave-plugin@2026.6.10-beta.1");
});
it("prefers the parsed exact npm spec package over inconsistent drift metadata", () => {
expect(
resolvePluginVersionDriftUpdateCommand({
pluginId: "brave",
installedVersion: "2026.6.9",
gatewayVersion: "2026.6.10-beta.1",
source: "npm",
packageName: "@openclaw/other-plugin",
spec: "@openclaw/brave-plugin@2026.6.9",
}),
).toBe("openclaw plugins update @openclaw/brave-plugin@2026.6.10-beta.1");
});
it("keeps plugin-id updates for floating npm install records", () => {
expect(
resolvePluginVersionDriftUpdateCommand({
pluginId: "brave",
installedVersion: "2026.6.9",
gatewayVersion: "2026.6.10-beta.1",
source: "npm",
packageName: "@openclaw/brave-plugin",
spec: "@openclaw/brave-plugin",
}),
).toBe("openclaw plugins update brave");
});
it("keeps plugin-id updates when the gateway version is not a registry version", () => {
expect(
resolvePluginVersionDriftUpdateCommand({
pluginId: "brave",
installedVersion: "2026.6.9",
gatewayVersion: "unknown",
source: "npm",
packageName: "@openclaw/brave-plugin",
spec: "@openclaw/brave-plugin@2026.6.9",
}),
).toBe("openclaw plugins update brave");
});
});