234 lines
8.2 KiB
TypeScript
234 lines
8.2 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
});
|
|
});
|