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", }, ]); }); it("migrates legacy unscoped install keys when a scoped npm package updates", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, pluginId: "@openclaw/voice-call", targetDir: "/tmp/openclaw-voice-call", version: "0.0.2", extensions: ["index.ts"], }); const { updateNpmInstalledPlugins } = await import("./update.js"); const result = await updateNpmInstalledPlugins({ config: { plugins: { allow: ["voice-call"], deny: ["voice-call"], slots: { memory: "voice-call" }, entries: { "voice-call": { enabled: false, hooks: { allowPromptInjection: false }, }, }, installs: { "voice-call": { source: "npm", spec: "@openclaw/voice-call", installPath: "/tmp/voice-call", }, }, }, }, pluginIds: ["voice-call"], }); expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( expect.objectContaining({ spec: "@openclaw/voice-call", expectedPluginId: "voice-call", }), ); expect(result.config.plugins?.allow).toEqual(["@openclaw/voice-call"]); expect(result.config.plugins?.deny).toEqual(["@openclaw/voice-call"]); expect(result.config.plugins?.slots?.memory).toBe("@openclaw/voice-call"); expect(result.config.plugins?.entries?.["@openclaw/voice-call"]).toEqual({ enabled: false, hooks: { allowPromptInjection: false }, }); expect(result.config.plugins?.entries?.["voice-call"]).toBeUndefined(); expect(result.config.plugins?.installs?.["@openclaw/voice-call"]).toMatchObject({ source: "npm", spec: "@openclaw/voice-call", installPath: "/tmp/openclaw-voice-call", version: "0.0.2", }); expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined(); }); }); 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(); }); it("forwards an explicit env to bundled plugin source resolution", async () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; const { syncPluginsForUpdateChannel } = await import("./update.js"); await syncPluginsForUpdateChannel({ channel: "beta", config: {}, workspaceDir: "/workspace", env, }); expect(resolveBundledPluginSourcesMock).toHaveBeenCalledWith({ workspaceDir: "/workspace", env, }); }); it("uses the provided env when matching bundled load and install paths", async () => { const bundledHome = "/tmp/openclaw-home"; resolveBundledPluginSourcesMock.mockReturnValue( new Map([ [ "feishu", { pluginId: "feishu", localPath: `${bundledHome}/plugins/feishu`, npmSpec: "@openclaw/feishu", }, ], ]), ); const previousHome = process.env.HOME; process.env.HOME = "/tmp/process-home"; try { const { syncPluginsForUpdateChannel } = await import("./update.js"); const result = await syncPluginsForUpdateChannel({ channel: "beta", env: { ...process.env, OPENCLAW_HOME: bundledHome, HOME: "/tmp/ignored-home", }, config: { plugins: { load: { paths: ["~/plugins/feishu"] }, installs: { feishu: { source: "path", sourcePath: "~/plugins/feishu", installPath: "~/plugins/feishu", spec: "@openclaw/feishu", }, }, }, }, }); expect(result.changed).toBe(false); expect(result.config.plugins?.load?.paths).toEqual(["~/plugins/feishu"]); expect(result.config.plugins?.installs?.feishu).toMatchObject({ source: "path", sourcePath: "~/plugins/feishu", installPath: "~/plugins/feishu", }); } finally { if (previousHome === undefined) { delete process.env.HOME; } else { process.env.HOME = previousHome; } } }); });