Files
Moltbot/src/plugins/update.test.ts
Tak Hoffman 74624e619d fix: prefer bundled channel plugins over npm duplicates (#40094)
* fix: prefer bundled channel plugins over npm duplicates

* fix: tighten bundled plugin review follow-ups

* fix: address check gate follow-ups

* docs: add changelog for bundled plugin install fix

* fix: align lifecycle test formatting with CI oxfmt
2026-03-08 13:00:24 -05:00

249 lines
6.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const installPluginFromNpmSpecMock = vi.fn();
const resolveBundledPluginSourcesMock = vi.fn();
vi.mock("./install.js", () => ({
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpecMock(...args),
resolvePluginInstallDir: (pluginId: string) => `/tmp/${pluginId}`,
PLUGIN_INSTALL_ERROR_CODE: {
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
},
}));
vi.mock("./bundled-sources.js", () => ({
resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args),
}));
describe("updateNpmInstalledPlugins", () => {
beforeEach(() => {
installPluginFromNpmSpecMock.mockReset();
resolveBundledPluginSourcesMock.mockReset();
});
it("skips integrity drift checks for unpinned npm specs during dry-run updates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
pluginId: "opik-openclaw",
targetDir: "/tmp/opik-openclaw",
version: "0.2.6",
extensions: ["index.ts"],
});
const { updateNpmInstalledPlugins } = await import("./update.js");
await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
"opik-openclaw": {
source: "npm",
spec: "@opik/opik-openclaw",
integrity: "sha512-old",
installPath: "/tmp/opik-openclaw",
},
},
},
},
pluginIds: ["opik-openclaw"],
dryRun: true,
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@opik/opik-openclaw",
expectedIntegrity: undefined,
}),
);
});
it("keeps integrity drift checks for exact-version npm specs during dry-run updates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
pluginId: "opik-openclaw",
targetDir: "/tmp/opik-openclaw",
version: "0.2.6",
extensions: ["index.ts"],
});
const { updateNpmInstalledPlugins } = await import("./update.js");
await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
"opik-openclaw": {
source: "npm",
spec: "@opik/opik-openclaw@0.2.5",
integrity: "sha512-old",
installPath: "/tmp/opik-openclaw",
},
},
},
},
pluginIds: ["opik-openclaw"],
dryRun: true,
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@opik/opik-openclaw@0.2.5",
expectedIntegrity: "sha512-old",
}),
);
});
it("formats package-not-found updates with a stable message", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: false,
code: "npm_package_not_found",
error: "Package not found on npm: @openclaw/missing.",
});
const { updateNpmInstalledPlugins } = await import("./update.js");
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
missing: {
source: "npm",
spec: "@openclaw/missing",
installPath: "/tmp/missing",
},
},
},
},
pluginIds: ["missing"],
dryRun: true,
});
expect(result.outcomes).toEqual([
{
pluginId: "missing",
status: "error",
message: "Failed to check missing: npm package not found for @openclaw/missing.",
},
]);
});
it("falls back to raw installer error for unknown error codes", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: false,
code: "invalid_npm_spec",
error: "unsupported npm spec: github:evil/evil",
});
const { updateNpmInstalledPlugins } = await import("./update.js");
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
bad: {
source: "npm",
spec: "github:evil/evil",
installPath: "/tmp/bad",
},
},
},
},
pluginIds: ["bad"],
dryRun: true,
});
expect(result.outcomes).toEqual([
{
pluginId: "bad",
status: "error",
message: "Failed to check bad: unsupported npm spec: github:evil/evil",
},
]);
});
});
describe("syncPluginsForUpdateChannel", () => {
beforeEach(() => {
installPluginFromNpmSpecMock.mockReset();
resolveBundledPluginSourcesMock.mockReset();
});
it("keeps bundled path installs on beta without reinstalling from npm", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(
new Map([
[
"feishu",
{
pluginId: "feishu",
localPath: "/app/extensions/feishu",
npmSpec: "@openclaw/feishu",
},
],
]),
);
const { syncPluginsForUpdateChannel } = await import("./update.js");
const result = await syncPluginsForUpdateChannel({
channel: "beta",
config: {
plugins: {
load: { paths: ["/app/extensions/feishu"] },
installs: {
feishu: {
source: "path",
sourcePath: "/app/extensions/feishu",
installPath: "/app/extensions/feishu",
spec: "@openclaw/feishu",
},
},
},
},
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(false);
expect(result.summary.switchedToNpm).toEqual([]);
expect(result.config.plugins?.load?.paths).toEqual(["/app/extensions/feishu"]);
expect(result.config.plugins?.installs?.feishu?.source).toBe("path");
});
it("repairs bundled install metadata when the load path is re-added", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(
new Map([
[
"feishu",
{
pluginId: "feishu",
localPath: "/app/extensions/feishu",
npmSpec: "@openclaw/feishu",
},
],
]),
);
const { syncPluginsForUpdateChannel } = await import("./update.js");
const result = await syncPluginsForUpdateChannel({
channel: "beta",
config: {
plugins: {
load: { paths: [] },
installs: {
feishu: {
source: "path",
sourcePath: "/app/extensions/feishu",
installPath: "/tmp/old-feishu",
spec: "@openclaw/feishu",
},
},
},
},
});
expect(result.changed).toBe(true);
expect(result.config.plugins?.load?.paths).toEqual(["/app/extensions/feishu"]);
expect(result.config.plugins?.installs?.feishu).toMatchObject({
source: "path",
sourcePath: "/app/extensions/feishu",
installPath: "/app/extensions/feishu",
spec: "@openclaw/feishu",
});
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
});
});