fix(ticket116): isoler crash plugin hello-plugin + erreur explicite UI
This commit is contained in:
@ -75,6 +75,7 @@ describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => {
|
||||
menuItems: [],
|
||||
layouts: [
|
||||
{ type: "dev.acme.good", label: "Good layout", component: "X" },
|
||||
null,
|
||||
{ type: "dev.acme.no-label", label: undefined, component: "X" },
|
||||
{ type: undefined, label: "No type", component: "X" },
|
||||
] as unknown as PluginContributionDto["layouts"],
|
||||
|
||||
@ -30,6 +30,10 @@ function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function finiteOrder(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
@ -42,8 +46,10 @@ export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): Plugin
|
||||
return registry
|
||||
.layoutContributions()
|
||||
.flatMap(({ pluginId, pluginDisplayName, layout }) => {
|
||||
const type = nonEmptyString(layout.type);
|
||||
const label = nonEmptyString(layout.label);
|
||||
const layoutObject = objectOrNull(layout);
|
||||
if (!layoutObject) return [];
|
||||
const type = nonEmptyString(layoutObject.type);
|
||||
const label = nonEmptyString(layoutObject.label);
|
||||
if (!type || !label) return [];
|
||||
return [
|
||||
{
|
||||
@ -53,8 +59,8 @@ export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): Plugin
|
||||
...layout,
|
||||
type,
|
||||
label,
|
||||
order: finiteOrder(layout.order),
|
||||
icon: nonEmptyString(layout.icon),
|
||||
order: finiteOrder(layoutObject.order),
|
||||
icon: nonEmptyString(layoutObject.icon),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* acceptance criteria: "ordre déterministe, disabledReason, plugin disabled
|
||||
* absent, command handler appelé").
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PluginContributionDto } from "@/domain";
|
||||
import { PluginRuntimeRegistry, type LoadedPlugin, type WhenContext } from "@/plugins/runtime";
|
||||
@ -19,11 +19,15 @@ const NO_CONTEXT: WhenContext = {
|
||||
};
|
||||
|
||||
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(contributes.menuItems.map((i) => i.command))),
|
||||
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 () => {},
|
||||
@ -67,6 +71,7 @@ describe("resolveTopLevelMenus", () => {
|
||||
...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 },
|
||||
@ -167,6 +172,7 @@ describe("resolveMenuItems", () => {
|
||||
label: "Say Hello",
|
||||
command: "hello-plugin.sayHello",
|
||||
},
|
||||
null,
|
||||
{
|
||||
id: "hello-plugin.broken.item",
|
||||
targetMenuId: "hello-plugin.menu",
|
||||
@ -199,4 +205,29 @@ describe("resolveMenuItems", () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -22,6 +22,10 @@ function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function finiteOrder(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
@ -44,8 +48,10 @@ export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedT
|
||||
return registry
|
||||
.topLevelMenus()
|
||||
.flatMap(({ pluginId, pluginDisplayName, menu }) => {
|
||||
const id = nonEmptyString(menu.id);
|
||||
const label = nonEmptyString(menu.label);
|
||||
const menuObject = objectOrNull(menu);
|
||||
if (!menuObject) return [];
|
||||
const id = nonEmptyString(menuObject.id);
|
||||
const label = nonEmptyString(menuObject.label);
|
||||
if (!id || !label) return [];
|
||||
return [
|
||||
{
|
||||
@ -53,8 +59,8 @@ export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedT
|
||||
pluginId,
|
||||
pluginDisplayName,
|
||||
label,
|
||||
icon: nonEmptyString(menu.icon),
|
||||
order: finiteOrder(menu.order),
|
||||
icon: nonEmptyString(menuObject.icon),
|
||||
order: finiteOrder(menuObject.order),
|
||||
},
|
||||
];
|
||||
})
|
||||
@ -80,13 +86,14 @@ export function resolveMenuItems(
|
||||
): ResolvedPluginMenuItem[] {
|
||||
return registry
|
||||
.menuItems()
|
||||
.filter(({ item }) => targetMatches(item.targetMenuId, targetMenuId))
|
||||
.flatMap(({ pluginId, pluginDisplayName, item }) => {
|
||||
const id = nonEmptyString(item.id);
|
||||
const label = nonEmptyString(item.label);
|
||||
const command = nonEmptyString(item.command);
|
||||
const itemObject = objectOrNull(item);
|
||||
if (!itemObject || !targetMatches(itemObject.targetMenuId, targetMenuId)) return [];
|
||||
const id = nonEmptyString(itemObject.id);
|
||||
const label = nonEmptyString(itemObject.label);
|
||||
const command = nonEmptyString(itemObject.command);
|
||||
if (!id || !label || !command) return [];
|
||||
const result = evaluateWhen(item.when, whenCtx);
|
||||
const result = evaluateWhen(nonEmptyString(itemObject.when), whenCtx);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
@ -98,8 +105,8 @@ export function resolveMenuItems(
|
||||
enabled: result.ok ? result.value : false,
|
||||
disabledReason: result.ok ? undefined : result.reason,
|
||||
groupLabel: nonEmptyString(pluginDisplayName) ?? pluginId,
|
||||
order: finiteOrder(item.order),
|
||||
iconUrl: nonEmptyString(item.icon),
|
||||
order: finiteOrder(itemObject.order),
|
||||
iconUrl: nonEmptyString(itemObject.icon),
|
||||
} satisfies ResolvedPluginMenuItem,
|
||||
];
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user