From e6911f0448001d18d9df1b0a27cc2cc7b8ef6df8 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:05:04 -0500 Subject: [PATCH] Tests: restore deterministic plugins CLI coverage (#49955) * Tests: restore deterministic plugins CLI coverage * CLI: preserve plugins exit control-flow narrowing * Tests: fix plugins CLI mock typing for tsgo * Tests: fix provider usage mock typing in key normalization --- src/cli/plugins-cli.test.ts | 424 ++++++++++++++++++ src/cli/plugins-cli.ts | 34 +- ...rovider-usage.auth.normalizes-keys.test.ts | 3 +- 3 files changed, 442 insertions(+), 19 deletions(-) create mode 100644 src/cli/plugins-cli.test.ts diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts new file mode 100644 index 00000000000..50bc8633e70 --- /dev/null +++ b/src/cli/plugins-cli.test.ts @@ -0,0 +1,424 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const loadConfig = vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig); +const writeConfigFile = vi.fn<(config: OpenClawConfig) => Promise>(async () => undefined); +const resolveStateDir = vi.fn(() => "/tmp/openclaw-state"); +const installPluginFromMarketplace = vi.fn(); +const listMarketplacePlugins = vi.fn(); +const resolveMarketplaceInstallShortcut = vi.fn(); +const enablePluginInConfig = vi.fn(); +const recordPluginInstall = vi.fn(); +const clearPluginManifestRegistryCache = vi.fn(); +const buildPluginStatusReport = vi.fn(); +const applyExclusiveSlotSelection = vi.fn(); +const uninstallPlugin = vi.fn(); +const updateNpmInstalledPlugins = vi.fn(); +const promptYesNo = vi.fn(); +const installPluginFromNpmSpec = vi.fn(); +const installPluginFromPath = vi.fn(); + +const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = + createCliRuntimeCapture(); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => loadConfig(), + writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config), + }; +}); + +vi.mock("../config/paths.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStateDir: () => resolveStateDir(), + }; +}); + +vi.mock("../plugins/marketplace.js", () => ({ + installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplace(...args), + listMarketplacePlugins: (...args: unknown[]) => listMarketplacePlugins(...args), + resolveMarketplaceInstallShortcut: (...args: unknown[]) => + resolveMarketplaceInstallShortcut(...args), +})); + +vi.mock("../plugins/enable.js", () => ({ + enablePluginInConfig: (...args: unknown[]) => enablePluginInConfig(...args), +})); + +vi.mock("../plugins/installs.js", () => ({ + recordPluginInstall: (...args: unknown[]) => recordPluginInstall(...args), +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(), +})); + +vi.mock("../plugins/status.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args), + }; +}); + +vi.mock("../plugins/slots.js", () => ({ + applyExclusiveSlotSelection: (...args: unknown[]) => applyExclusiveSlotSelection(...args), +})); + +vi.mock("../plugins/uninstall.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args), + }; +}); + +vi.mock("../plugins/update.js", () => ({ + updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), +})); + +vi.mock("./prompt.js", () => ({ + promptYesNo: (...args: unknown[]) => promptYesNo(...args), +})); + +vi.mock("../plugins/install.js", () => ({ + installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args), + installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args), +})); + +const { registerPluginsCli } = await import("./plugins-cli.js"); + +describe("plugins cli", () => { + const createProgram = () => { + const program = new Command(); + program.exitOverride(); + registerPluginsCli(program); + return program; + }; + + const runCommand = (argv: string[]) => createProgram().parseAsync(argv, { from: "user" }); + + beforeEach(() => { + resetRuntimeCapture(); + loadConfig.mockReset(); + writeConfigFile.mockReset(); + resolveStateDir.mockReset(); + installPluginFromMarketplace.mockReset(); + listMarketplacePlugins.mockReset(); + resolveMarketplaceInstallShortcut.mockReset(); + enablePluginInConfig.mockReset(); + recordPluginInstall.mockReset(); + clearPluginManifestRegistryCache.mockReset(); + buildPluginStatusReport.mockReset(); + applyExclusiveSlotSelection.mockReset(); + uninstallPlugin.mockReset(); + updateNpmInstalledPlugins.mockReset(); + promptYesNo.mockReset(); + installPluginFromNpmSpec.mockReset(); + installPluginFromPath.mockReset(); + + loadConfig.mockReturnValue({} as OpenClawConfig); + writeConfigFile.mockResolvedValue(undefined); + resolveStateDir.mockReturnValue("/tmp/openclaw-state"); + resolveMarketplaceInstallShortcut.mockResolvedValue(null); + installPluginFromMarketplace.mockResolvedValue({ + ok: false, + error: "marketplace install failed", + }); + enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg })); + recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg); + buildPluginStatusReport.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({ + config, + warnings: [], + })); + uninstallPlugin.mockResolvedValue({ + ok: true, + config: {} as OpenClawConfig, + warnings: [], + actions: { + entry: false, + install: false, + allowlist: false, + loadPath: false, + memorySlot: false, + directory: false, + }, + }); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [], + changed: false, + config: {} as OpenClawConfig, + }); + promptYesNo.mockResolvedValue(true); + installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "npm install disabled in test", + }); + }); + + it("exits when --marketplace is combined with --link", async () => { + await expect( + runCommand(["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( + runCommand(["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("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 runCommand(["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("shows uninstall dry-run preview without mutating config", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + alpha: { + enabled: true, + }, + }, + installs: { + alpha: { + source: "path", + sourcePath: "/tmp/openclaw-state/extensions/alpha", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + + await runCommand(["plugins", "uninstall", "alpha", "--dry-run"]); + + expect(uninstallPlugin).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); + }); + + it("uninstalls with --force and --keep-files without prompting", async () => { + const baseConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + installs: { + alpha: { + source: "path", + sourcePath: "/tmp/openclaw-state/extensions/alpha", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(baseConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + uninstallPlugin.mockResolvedValue({ + ok: true, + config: nextConfig, + warnings: [], + actions: { + entry: true, + install: true, + allowlist: false, + loadPath: false, + memorySlot: false, + directory: false, + }, + }); + + await runCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]); + + expect(promptYesNo).not.toHaveBeenCalled(); + expect(uninstallPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "alpha", + deleteFiles: false, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + }); + + it("exits when uninstall target is not managed by plugin install records", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + + await expect(runCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records"); + expect(uninstallPlugin).not.toHaveBeenCalled(); + }); + + it("exits when update is called without id and without --all", async () => { + loadConfig.mockReturnValue({ + plugins: { + installs: {}, + }, + } as OpenClawConfig); + + await expect(runCommand(["plugins", "update"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("Provide a plugin id or use --all."); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + }); + + it("reports no tracked plugins when update --all has empty install records", async () => { + loadConfig.mockReturnValue({ + plugins: { + installs: {}, + }, + } as OpenClawConfig); + + await runCommand(["plugins", "update", "--all"]); + + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toBe("No tracked plugins to update."); + }); + + it("writes updated config when updater reports changes", async () => { + const cfg = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.0.0", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.1.0", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }], + changed: true, + config: nextConfig, + }); + + await runCommand(["plugins", "update", "alpha"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + pluginIds: ["alpha"], + dryRun: false, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect(runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins."))).toBe( + true, + ); + }); +}); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index b180b0a38e8..79fca829281 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -288,7 +288,7 @@ async function runPluginInstallCommand(params: { : null; if (shorthand?.ok === false) { defaultRuntime.error(shorthand.error); - process.exit(1); + return defaultRuntime.exit(1); } const raw = shorthand?.ok ? shorthand.plugin : params.raw; @@ -301,11 +301,11 @@ async function runPluginInstallCommand(params: { if (opts.marketplace) { if (opts.link) { defaultRuntime.error("`--link` is not supported with `--marketplace`."); - process.exit(1); + return defaultRuntime.exit(1); } if (opts.pin) { defaultRuntime.error("`--pin` is not supported with `--marketplace`."); - process.exit(1); + return defaultRuntime.exit(1); } const cfg = loadConfig(); @@ -316,7 +316,7 @@ async function runPluginInstallCommand(params: { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } clearPluginManifestRegistryCache(); @@ -343,7 +343,7 @@ async function runPluginInstallCommand(params: { const fileSpec = resolveFileNpmSpecToLocalPath(raw); if (fileSpec && !fileSpec.ok) { defaultRuntime.error(fileSpec.error); - process.exit(1); + return defaultRuntime.exit(1); } const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw; const resolved = resolveUserPath(normalized); @@ -356,7 +356,7 @@ async function runPluginInstallCommand(params: { const probe = await installPluginFromPath({ path: resolved, dryRun: true }); if (!probe.ok) { defaultRuntime.error(probe.error); - process.exit(1); + return defaultRuntime.exit(1); } let next: OpenClawConfig = enablePluginInConfig( @@ -394,7 +394,7 @@ async function runPluginInstallCommand(params: { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } // Plugin CLI registrars may have warmed the manifest registry cache before install; // force a rescan so config validation sees the freshly installed plugin. @@ -420,7 +420,7 @@ async function runPluginInstallCommand(params: { if (opts.link) { defaultRuntime.error("`--link` requires a local path."); - process.exit(1); + return defaultRuntime.exit(1); } if ( @@ -436,7 +436,7 @@ async function runPluginInstallCommand(params: { ]) ) { defaultRuntime.error(`Path not found: ${resolved}`); - process.exit(1); + return defaultRuntime.exit(1); } const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({ @@ -465,7 +465,7 @@ async function runPluginInstallCommand(params: { }); if (!bundledFallbackPlan) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } await installBundledPluginSource({ @@ -623,7 +623,7 @@ export function registerPluginsCli(program: Command) { if (opts.all) { if (id) { defaultRuntime.error("Pass either a plugin id or --all, not both."); - process.exit(1); + return defaultRuntime.exit(1); } const inspectAll = buildAllPluginInspectReports({ config: cfg, @@ -689,7 +689,7 @@ export function registerPluginsCli(program: Command) { if (!id) { defaultRuntime.error("Provide a plugin id or use --all."); - process.exit(1); + return defaultRuntime.exit(1); } const inspect = buildPluginInspectReport({ @@ -699,7 +699,7 @@ export function registerPluginsCli(program: Command) { }); if (!inspect) { defaultRuntime.error(`Plugin not found: ${id}`); - process.exit(1); + return defaultRuntime.exit(1); } const install = cfg.plugins?.installs?.[inspect.plugin.id]; @@ -905,7 +905,7 @@ export function registerPluginsCli(program: Command) { } else { defaultRuntime.error(`Plugin not found: ${id}`); } - process.exit(1); + return defaultRuntime.exit(1); } const install = cfg.plugins?.installs?.[pluginId]; @@ -972,7 +972,7 @@ export function registerPluginsCli(program: Command) { if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } for (const warning of result.warnings) { defaultRuntime.log(theme.warn(warning)); @@ -1040,7 +1040,7 @@ export function registerPluginsCli(program: Command) { return; } defaultRuntime.error("Provide a plugin id or use --all."); - process.exit(1); + return defaultRuntime.exit(1); } const result = await updateNpmInstalledPlugins({ @@ -1148,7 +1148,7 @@ export function registerPluginsCli(program: Command) { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } if (opts.json) { diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 27d52b418cd..2408a28a9bd 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -7,8 +7,7 @@ import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; const resolveProviderUsageAuthWithPluginMock = vi.fn(async (..._args: unknown[]) => null); vi.mock("../plugins/provider-runtime.js", () => ({ - resolveProviderUsageAuthWithPlugin: (...args: unknown[]) => - resolveProviderUsageAuthWithPluginMock(...args), + resolveProviderUsageAuthWithPlugin: resolveProviderUsageAuthWithPluginMock, })); let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths;