/** * Routing tests for `omp plugin install ` (#1945). * * Two layers of coverage: * 1. Spy-based: `runPluginCommand` with a local path calls * `PluginManager.link` and NEVER `PluginManager.install` (the npm path * that produced `Invalid package name: .`). * 2. End-to-end: with a real on-disk plugin directory, the install routes * through `link` and produces the symlink + lockfile entry users expect. * * `flags.json` is set everywhere so the renderer takes the JSON branch and * avoids the theme (`runPluginCommand` does not initialize the theme on its * own — `commands/plugin.ts` does). */ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { runPluginCommand } from "@oh-my-pi/pi-coding-agent/cli/plugin-cli"; import { PluginManager } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/manager"; import { MarketplaceManager } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace"; import type { InstalledPlugin } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/types"; import * as piUtils from "@oh-my-pi/pi-utils"; const FAKE_INSTALLED: InstalledPlugin = { name: "kimi-datasource", version: "1.0.0", path: "/tmp/fake/plugins/node_modules/kimi-datasource", manifest: { version: "1.0.0" }, enabledFeatures: null, enabled: true, }; describe("runPluginCommand({ action: 'install', args: [] })", () => { let tmpRoot: string; beforeEach(async () => { tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-plugin-install-local-")); const pluginsDir = path.join(tmpRoot, "plugins"); await fs.mkdir(path.join(pluginsDir, "node_modules"), { recursive: true }); spyOn(piUtils, "getPluginsDir").mockReturnValue(pluginsDir); spyOn(piUtils, "getPluginsNodeModules").mockReturnValue(path.join(pluginsDir, "node_modules")); spyOn(piUtils, "getPluginsPackageJson").mockReturnValue(path.join(pluginsDir, "package.json")); spyOn(piUtils, "getPluginsLockfile").mockReturnValue(path.join(tmpRoot, "omp-plugins.lock.json")); spyOn(piUtils, "getProjectDir").mockReturnValue(tmpRoot); spyOn(piUtils, "getProjectPluginOverridesPath").mockReturnValue(path.join(tmpRoot, "plugin-overrides.json")); // runPluginCommand always builds a MarketplaceManager to enumerate // registered marketplaces. Stub the registry list so classification has // no marketplace candidates to confuse local paths with. spyOn(MarketplaceManager.prototype, "listMarketplaces").mockResolvedValue([]); // Swallow CLI output so test logs stay clean. spyOn(console, "log").mockImplementation(() => undefined); spyOn(console, "error").mockImplementation(() => undefined); }); afterEach(async () => { // Restore every spy installed in beforeEach plus the per-test // linkSpy/installSpy/console spies. Without this, the piUtils.* // stubs leak into sibling test files (e.g. marketplace/manager.test.ts // breaks because listMarketplaces() still returns []). mock.restore(); await fs.rm(tmpRoot, { recursive: true, force: true }); }); for (const spec of [".", "./pkg", "../pkg", "/abs/pkg", "~/pkg"]) { test(`dispatches ${JSON.stringify(spec)} to link() instead of install()`, async () => { const linkSpy = spyOn(PluginManager.prototype, "link").mockResolvedValue(FAKE_INSTALLED); const installSpy = spyOn(PluginManager.prototype, "install").mockResolvedValue(FAKE_INSTALLED); try { await runPluginCommand({ action: "install", args: [spec], flags: { json: true } }); expect(linkSpy).toHaveBeenCalledTimes(1); expect(linkSpy.mock.calls[0]?.[0]).toBe(spec); expect(installSpy).not.toHaveBeenCalled(); } finally { linkSpy.mockRestore(); installSpy.mockRestore(); } }); } test("npm-style spec still dispatches to install(), not link()", async () => { // Guard against an overly-eager local detector: a bare package name with // no path-like prefix must continue down the npm path. const linkSpy = spyOn(PluginManager.prototype, "link").mockResolvedValue(FAKE_INSTALLED); const installSpy = spyOn(PluginManager.prototype, "install").mockResolvedValue(FAKE_INSTALLED); try { await runPluginCommand({ action: "install", args: ["some-pkg"], flags: { json: true } }); expect(installSpy).toHaveBeenCalledTimes(1); expect(installSpy.mock.calls[0]?.[0]).toBe("some-pkg"); expect(linkSpy).not.toHaveBeenCalled(); } finally { linkSpy.mockRestore(); installSpy.mockRestore(); } }); test("--dry-run on a local path neither links nor installs", async () => { const linkSpy = spyOn(PluginManager.prototype, "link").mockResolvedValue(FAKE_INSTALLED); const installSpy = spyOn(PluginManager.prototype, "install").mockResolvedValue(FAKE_INSTALLED); try { await runPluginCommand({ action: "install", args: ["."], flags: { dryRun: true, json: true } }); expect(linkSpy).not.toHaveBeenCalled(); expect(installSpy).not.toHaveBeenCalled(); } finally { linkSpy.mockRestore(); installSpy.mockRestore(); } }); test("real local plugin directory: install symlinks it like link would", async () => { // End-to-end: stage a real plugin folder, route through plugin-cli // (no spies on PluginManager.link), and verify the resulting symlink // + lockfile entry. Pins the contract that local-path installs // symlink rather than copy-install, matching `omp plugin link`. const localPlugin = path.join(tmpRoot, "kimi-datasource"); await fs.mkdir(localPlugin, { recursive: true }); await Bun.write( path.join(localPlugin, "package.json"), JSON.stringify({ name: "kimi-datasource", version: "1.0.0", omp: { extensions: ["./src/extension.ts"] }, }), ); await runPluginCommand({ action: "install", args: [localPlugin], flags: { json: true } }); const linkTarget = path.join(tmpRoot, "plugins", "node_modules", "kimi-datasource"); const stat = await fs.lstat(linkTarget); expect(stat.isSymbolicLink()).toBe(true); expect(await fs.readlink(linkTarget)).toBe(localPlugin); const lock = await Bun.file(path.join(tmpRoot, "omp-plugins.lock.json")).json(); expect(lock.plugins["kimi-datasource"]).toEqual({ version: "1.0.0", enabledFeatures: null, enabled: true, }); }); });