fix(plugins): isole les menus plugin et durcit hello-plugin SDK contre la rechute #120
Requalification Architect du 2026-08-01: le crash INTERFACE INTERROMPUE au chargement d'un plugin (menu + item) remonte via ProjectsView -> usePluginMenus -> MenuBar jusqu'à RootErrorBoundary, quel que soit le plugin (ancien ou reconstruit via SDK). - usePluginMenus isole la résolution/conversion des contributions plugin : toute erreur retombe sur [] au lieu de propager. - ProjectsView sépare les menus natifs des menus enrichis par plugin et rend MenuBar derrière une error boundary locale (fallback menus natifs seuls). - hello-plugin (SDK) et les tests d'installation associés durcis en cohérence. Bookkeeping ticket #120 uniquement (issue.md, carnet.md) ; les fichiers counter.json/index.json et le dossier tickets/122/ restent hors commit car une collision de numérotation #122 existe entre cette base et feature/ticket120-hello-plugin-install-path-audit (deux tickets différents revendiquent #122) — à arbitrer avant de committer le bookkeeping global. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
75
frontend/src/features/plugins/usePluginMenus.test.tsx
Normal file
75
frontend/src/features/plugins/usePluginMenus.test.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MenuTargetId } from "@/domain";
|
||||
import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime";
|
||||
import { usePluginMenus } from "./usePluginMenus";
|
||||
|
||||
const NO_CONTEXT: WhenContext = {
|
||||
projectOpen: false,
|
||||
gitRepository: false,
|
||||
agentSelected: false,
|
||||
terminalFocused: false,
|
||||
layoutCellFocused: false,
|
||||
};
|
||||
|
||||
function throwingRegistry(overrides: Record<string, unknown>): PluginRuntimeRegistry {
|
||||
return {
|
||||
runCommand: vi.fn(async () => {}),
|
||||
topLevelMenus: vi.fn(() => []),
|
||||
menuItems: vi.fn(() => []),
|
||||
...overrides,
|
||||
} as unknown as PluginRuntimeRegistry;
|
||||
}
|
||||
|
||||
describe("usePluginMenus", () => {
|
||||
it("falls back to no plugin top-level menus when plugin menu resolution throws", () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const registry = throwingRegistry({
|
||||
topLevelMenus: vi.fn(() => {
|
||||
throw new Error("broken top-level contribution");
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
|
||||
|
||||
expect(result.current.topLevelMenus).toEqual([]);
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[plugins] menu contribution rejected",
|
||||
expect.objectContaining({
|
||||
scope: "top-level-menus",
|
||||
reason: "broken top-level contribution",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
consoleWarn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to no plugin items for a native menu when item resolution throws", () => {
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const registry = throwingRegistry({
|
||||
menuItems: vi.fn(() => {
|
||||
throw new Error("broken item contribution");
|
||||
}),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePluginMenus(registry, NO_CONTEXT));
|
||||
const items = result.current.itemsFor("panels" as MenuTargetId);
|
||||
|
||||
expect(items).toEqual([]);
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[plugins] menu contribution rejected",
|
||||
expect.objectContaining({
|
||||
scope: "items",
|
||||
targetMenuId: "panels",
|
||||
reason: "broken item contribution",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
consoleWarn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -16,7 +16,7 @@ import { useMemo } from "react";
|
||||
import type { MenuTargetId } from "@/domain";
|
||||
import type { MenuBarItem, MenuBarMenu } from "@/shared";
|
||||
import type { PluginRuntimeRegistry, WhenContext } from "@/plugins/runtime";
|
||||
import { resolveMenuItems, resolveTopLevelMenus } from "./menus";
|
||||
import { resolveMenuItems, resolveTopLevelMenus, type ResolvedTopLevelMenu } from "./menus";
|
||||
|
||||
export interface UsePluginMenusResult {
|
||||
/** Top-level plugin menus, in order, ready to splice between Panneaux/Paramètres. */
|
||||
@ -26,6 +26,25 @@ export interface UsePluginMenusResult {
|
||||
runCommand: (pluginId: string, commandId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function rejectPluginMenuContribution(
|
||||
scope: string,
|
||||
details: Record<string, unknown>,
|
||||
error: unknown,
|
||||
): void {
|
||||
console.warn("[plugins] menu contribution rejected", {
|
||||
scope,
|
||||
...details,
|
||||
reason: describeError(error),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePluginMenus(
|
||||
registry: PluginRuntimeRegistry,
|
||||
whenCtx: WhenContext,
|
||||
@ -36,27 +55,75 @@ export function usePluginMenus(
|
||||
);
|
||||
|
||||
const itemsFor = useMemo(() => {
|
||||
return (targetMenuId: MenuTargetId): MenuBarItem[] =>
|
||||
resolveMenuItems(registry, targetMenuId, whenCtx).map((item) => ({
|
||||
id: item.id,
|
||||
// Provenance is shown discreetly (carnet §0 UX decision) rather than
|
||||
// with a visual section divider, which the shared `MenuBar` primitive
|
||||
// doesn't support — a trailing "· Plugin Name" suffix is the least
|
||||
// intrusive option that still surfaces where the item came from.
|
||||
label: `${item.label} · ${item.pluginDisplayName}`,
|
||||
disabled: !item.enabled,
|
||||
onSelect: () => {
|
||||
void runCommand(item.pluginId, item.command);
|
||||
},
|
||||
}));
|
||||
return (targetMenuId: MenuTargetId): MenuBarItem[] => {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = resolveMenuItems(registry, targetMenuId, whenCtx);
|
||||
} catch (e) {
|
||||
rejectPluginMenuContribution("items", { targetMenuId }, e);
|
||||
return [];
|
||||
}
|
||||
|
||||
return resolved.flatMap((item) => {
|
||||
try {
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
// Provenance is shown discreetly (carnet §0 UX decision) rather than
|
||||
// with a visual section divider, which the shared `MenuBar` primitive
|
||||
// doesn't support — a trailing "· Plugin Name" suffix is the least
|
||||
// intrusive option that still surfaces where the item came from.
|
||||
label: `${item.label} · ${item.pluginDisplayName}`,
|
||||
disabled: !item.enabled,
|
||||
onSelect: () => {
|
||||
void runCommand(item.pluginId, item.command);
|
||||
},
|
||||
},
|
||||
];
|
||||
} catch (e) {
|
||||
rejectPluginMenuContribution(
|
||||
"item",
|
||||
{
|
||||
pluginId: item.pluginId,
|
||||
itemId: item.id,
|
||||
targetMenuId,
|
||||
commandId: item.command,
|
||||
},
|
||||
e,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [registry, whenCtx, runCommand]);
|
||||
|
||||
const topLevelMenus = useMemo<MenuBarMenu[]>(() => {
|
||||
return resolveTopLevelMenus(registry).map((menu) => ({
|
||||
id: menu.id,
|
||||
label: menu.label,
|
||||
items: itemsFor(menu.id),
|
||||
}));
|
||||
let resolved: ResolvedTopLevelMenu[];
|
||||
try {
|
||||
resolved = resolveTopLevelMenus(registry);
|
||||
} catch (e) {
|
||||
rejectPluginMenuContribution("top-level-menus", {}, e);
|
||||
return [];
|
||||
}
|
||||
|
||||
return resolved.flatMap((menu) => {
|
||||
try {
|
||||
return [
|
||||
{
|
||||
id: menu.id,
|
||||
label: menu.label,
|
||||
items: itemsFor(menu.id),
|
||||
},
|
||||
];
|
||||
} catch (e) {
|
||||
rejectPluginMenuContribution(
|
||||
"top-level-menu",
|
||||
{ pluginId: menu.pluginId, menuId: menu.id },
|
||||
e,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}, [registry, itemsFor]);
|
||||
|
||||
return { topLevelMenus, itemsFor, runCommand };
|
||||
|
||||
Reference in New Issue
Block a user