/** * F3 — plugin menu contribution resolution (ticket #43, carnet §10 F3 * acceptance criteria: "ordre déterministe, disabledReason, plugin disabled * absent, command handler appelé"). */ import { describe, expect, it, vi } from "vitest"; import type { PluginContributionDto } from "@/domain"; import { PluginRuntimeRegistry, type LoadedPlugin, type WhenContext } from "@/plugins/runtime"; import { PluginCommandRegistry, PluginLayoutRegistry, PluginMenuRegistry } from "@/plugins/runtime"; import { resolveMenuItems, resolveTopLevelMenus } from "./menus"; const NO_CONTEXT: WhenContext = { projectOpen: false, gitRepository: false, agentSelected: false, terminalFocused: false, layoutCellFocused: false, }; function stubPlugin(pluginId: string, displayName: string, contributes: PluginContributionDto): LoadedPlugin { const declaredCommandIds = contributes.menuItems.flatMap((item) => { if (item === null || typeof item !== "object") return []; return typeof item.command === "string" ? [item.command] : []; }); return { pluginId, displayName, contributes, commands: new PluginCommandRegistry(pluginId, new Set(declaredCommandIds)), layouts: new PluginLayoutRegistry(pluginId, new Set(contributes.layouts.map((l) => l.type))), menu: new PluginMenuRegistry(pluginId), dispose: async () => {}, }; } function empty(): PluginContributionDto { return { menus: [], menuItems: [], layouts: [], mcpServers: [] }; } describe("resolveTopLevelMenus", () => { it("sorts by order, then plugin display name, then label", () => { const registry = new PluginRuntimeRegistry(); registry.add( stubPlugin("dev.b", "Beta", { ...empty(), menus: [{ id: "b.menu", label: "B Menu", topLevel: true, order: 0 }], }), ); registry.add( stubPlugin("dev.a", "Alpha", { ...empty(), menus: [{ id: "a.menu", label: "A Menu", topLevel: true, order: 0 }], }), ); registry.add( stubPlugin("dev.z", "Zulu", { ...empty(), menus: [{ id: "z.menu", label: "Z Menu", topLevel: true, order: -1 }], }), ); const resolved = resolveTopLevelMenus(registry); expect(resolved.map((m) => m.label)).toEqual(["Z Menu", "A Menu", "B Menu"]); }); it("drops malformed top-level menus instead of throwing during resolution", () => { const registry = new PluginRuntimeRegistry(); registry.add( stubPlugin("dev.bad", "Bad", { ...empty(), menus: [ { id: "bad.menu", label: "Good", topLevel: true }, null, { id: "bad.empty", label: "", topLevel: true }, { id: undefined, label: "No id", topLevel: true }, { id: "bad.no-label", label: undefined, topLevel: true }, ] as unknown as PluginContributionDto["menus"], }), ); expect(resolveTopLevelMenus(registry).map((m) => m.label)).toEqual(["Good"]); }); }); describe("resolveMenuItems", () => { it("only returns items targeting the requested menu, and only from loaded (i.e. enabled) plugins", () => { const registry = new PluginRuntimeRegistry(); registry.add( stubPlugin("dev.acme", "Acme", { ...empty(), menuItems: [ { id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" }, { id: "dev.acme.b", targetMenuId: "settings", label: "Open B", command: "dev.acme.b.cmd" }, ], }), ); // A disabled plugin never appears in the registry at all (carnet §1.3) — // simulated here by simply not adding it. const panelsItems = resolveMenuItems(registry, "panels", NO_CONTEXT); expect(panelsItems).toHaveLength(1); expect(panelsItems[0].label).toBe("Open A"); const settingsItems = resolveMenuItems(registry, "settings", NO_CONTEXT); expect(settingsItems).toHaveLength(1); expect(settingsItems[0].label).toBe("Open B"); }); it("evaluates `when` and disables with a diagnostic reason on failure, enables on success", () => { const registry = new PluginRuntimeRegistry(); registry.add( stubPlugin("dev.acme", "Acme", { ...empty(), menuItems: [ { id: "dev.acme.needs-git", targetMenuId: "panels", label: "Needs git", command: "dev.acme.git.cmd", when: "gitRepository", }, { id: "dev.acme.broken-when", targetMenuId: "panels", label: "Broken when", command: "dev.acme.broken.cmd", when: "notAVariable", }, ], }), ); const withoutGit = resolveMenuItems(registry, "panels", NO_CONTEXT); const needsGit = withoutGit.find((i) => i.label === "Needs git")!; expect(needsGit.enabled).toBe(false); expect(needsGit.disabledReason).toBeUndefined(); const withGit = resolveMenuItems(registry, "panels", { ...NO_CONTEXT, gitRepository: true }); expect(withGit.find((i) => i.label === "Needs git")!.enabled).toBe(true); const broken = withoutGit.find((i) => i.label === "Broken when")!; expect(broken.enabled).toBe(false); expect(broken.disabledReason).toMatch(/unknown variable/); }); it("sorts resolved items deterministically", () => { const registry = new PluginRuntimeRegistry(); registry.add( stubPlugin("dev.acme", "Acme", { ...empty(), menuItems: [ { id: "dev.acme.z", targetMenuId: "panels", label: "Z item", command: "dev.acme.z.cmd", order: 1 }, { id: "dev.acme.a", targetMenuId: "panels", label: "A item", command: "dev.acme.a.cmd", order: 0 }, ], }), ); const items = resolveMenuItems(registry, "panels", NO_CONTEXT); expect(items.map((i) => i.label)).toEqual(["A item", "Z item"]); }); it("resolves hello-plugin's bare plugin menu target and skips malformed items", () => { const registry = new PluginRuntimeRegistry(); registry.add( stubPlugin("com.example.hello-plugin", "Hello Plugin", { ...empty(), menus: [{ id: "hello-plugin.menu", label: "Hello", topLevel: true }], menuItems: [ { id: "hello-plugin.sayHello.item", targetMenuId: "hello-plugin.menu", label: "Say Hello", command: "hello-plugin.sayHello", }, null, { id: "hello-plugin.broken.item", targetMenuId: "hello-plugin.menu", label: undefined, command: "hello-plugin.broken", }, ] as unknown as PluginContributionDto["menuItems"], }), ); const menus = resolveTopLevelMenus(registry); expect(menus.map((m) => m.id)).toEqual(["plugin:hello-plugin.menu"]); const items = resolveMenuItems(registry, "plugin:hello-plugin.menu", NO_CONTEXT); expect(items.map((i) => i.label)).toEqual(["Say Hello"]); expect(items[0].targetMenuId).toBe("plugin:hello-plugin.menu"); }); it("dispatches a command via the registry's runCommand", async () => { const registry = new PluginRuntimeRegistry(); let ran = false; const plugin = stubPlugin("dev.acme", "Acme", { ...empty(), menuItems: [{ id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" }], }); plugin.commands.register("dev.acme.a.cmd", () => { ran = true; }); registry.add(plugin); await registry.runCommand("dev.acme", "dev.acme.a.cmd"); expect(ran).toBe(true); }); it("confines a throwing command handler to the plugin command dispatch", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); try { const registry = new PluginRuntimeRegistry(); const plugin = stubPlugin("dev.acme", "Acme", { ...empty(), menuItems: [ { id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" }, ], }); plugin.commands.register("dev.acme.a.cmd", () => { throw new Error("boom"); }); registry.add(plugin); await expect(registry.runCommand("dev.acme", "dev.acme.a.cmd")).resolves.toBeUndefined(); expect(consoleError).toHaveBeenCalledWith( '[plugin:dev.acme] command "dev.acme.a.cmd" failed', expect.any(Error), ); } finally { consoleError.mockRestore(); } }); });