From aa850374cef3257bf22796ec9d28094e84d5d834 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 30 Jul 2026 09:08:14 +0200 Subject: [PATCH] fix(plugins): isole les contributions plugin en erreur et durcit menus.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le chargement de l'archive hello-plugin (build/hello-plugin-0.1.0.zip) vidait la fenêtre principale : une contribution plugin fautive remontait jusqu'au rendu global au lieu de rester locale à la cellule. Ajoute un boundary local dans PluginLayoutCellView/PluginLayoutSelectorSection et durcit menus.ts/loader.ts/registry.ts contre les entrées de menu ou contributions malformées. Co-Authored-By: Claude Sonnet 5 --- .../plugins/PluginLayoutCellView.test.tsx | 24 +++++- .../features/plugins/PluginLayoutCellView.tsx | 84 ++++++++++++++---- .../PluginLayoutSelectorSection.test.tsx | 29 +++++++ .../plugins/PluginLayoutSelectorSection.tsx | 35 +++++++- frontend/src/features/plugins/menus.test.ts | 47 ++++++++++ frontend/src/features/plugins/menus.ts | 85 +++++++++++++------ frontend/src/plugins/runtime/loader.test.ts | 37 ++++++++ frontend/src/plugins/runtime/loader.ts | 23 ++++- frontend/src/plugins/runtime/registry.ts | 10 ++- 9 files changed, 316 insertions(+), 58 deletions(-) diff --git a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx index a3d20d4..71bc41e 100644 --- a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx +++ b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx @@ -2,7 +2,7 @@ * F4 — `PluginLayoutCellView` (ticket #43, carnet §10 F4 acceptance criteria: * "rendu composant mock, state roundtrip, fallback sans mutation du layout"). */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import type { CustomPluginLayoutCell } from "@/domain"; @@ -39,7 +39,11 @@ function MockLayoutComponent(props: PluginLayoutProps) { ); } -function stubPlugin(): LoadedPlugin { +function ThrowingLayoutComponent(_props: PluginLayoutProps): JSX.Element { + throw new Error("plugin layout render failed"); +} + +function stubPlugin(component = MockLayoutComponent): LoadedPlugin { const contributes = { menus: [], menuItems: [], @@ -47,7 +51,7 @@ function stubPlugin(): LoadedPlugin { mcpServers: [], }; const layouts = new PluginLayoutRegistry("dev.acme.gitgraph", new Set(["dev.acme.gitgraph.layout"])); - layouts.register({ type: "dev.acme.gitgraph.layout", component: MockLayoutComponent }); + layouts.register({ type: "dev.acme.gitgraph.layout", component }); return { pluginId: "dev.acme.gitgraph", displayName: "Git Graph", @@ -110,4 +114,18 @@ describe("PluginLayoutCellView", () => { // The cell object itself is untouched — the domain identity/state survive. expect(testCell).toEqual(cell()); }); + + it("isolates a plugin layout render failure to the plugin cell fallback", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const registry = new PluginRuntimeRegistry(); + registry.add(stubPlugin(ThrowingLayoutComponent)); + renderCell(registry); + + expect(screen.getByText("Layout indisponible")).toBeTruthy(); + expect(screen.queryByTestId("state")).toBeNull(); + } finally { + consoleError.mockRestore(); + } + }); }); diff --git a/frontend/src/features/plugins/PluginLayoutCellView.tsx b/frontend/src/features/plugins/PluginLayoutCellView.tsx index beafa59..c06d7bc 100644 --- a/frontend/src/features/plugins/PluginLayoutCellView.tsx +++ b/frontend/src/features/plugins/PluginLayoutCellView.tsx @@ -10,6 +10,8 @@ * calls from inside a plugin component). */ +import { Component, type ErrorInfo, type ReactNode } from "react"; + import type { CustomPluginLayoutCell, PluginAdmin } from "@/domain"; import { useGateways } from "@/app/di"; import { usePluginRuntime } from "./PluginRuntimeProvider"; @@ -25,6 +27,38 @@ interface PluginLayoutCellViewProps { onChooseAnotherLayout: () => void; } +interface PluginLayoutErrorBoundaryProps { + resetKey: string; + fallback: ReactNode; + children: ReactNode; +} + +class PluginLayoutErrorBoundary extends Component< + PluginLayoutErrorBoundaryProps, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("[plugin-layout] render failed", error, errorInfo); + } + + componentDidUpdate(prevProps: PluginLayoutErrorBoundaryProps): void { + if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + render(): ReactNode { + if (this.state.hasError) return this.props.fallback; + return this.props.children; + } +} + export function PluginLayoutCellView({ projectId, cell, @@ -36,11 +70,11 @@ export function PluginLayoutCellView({ const { registry } = usePluginRuntime(); const gateways = useGateways(); const availability = resolvePluginLayoutAvailability(registry, cell, installedPlugins); + const providerDisplayName = + registry.get(cell.pluginId)?.displayName ?? + installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName; if (availability !== "available") { - const providerDisplayName = - registry.get(cell.pluginId)?.displayName ?? - installedPlugins?.find((p) => p.id === cell.pluginId)?.displayName; return ( ); + return ( + + + + ); } diff --git a/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx b/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx index f026c67..e66be38 100644 --- a/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx +++ b/frontend/src/features/plugins/PluginLayoutSelectorSection.test.tsx @@ -12,6 +12,7 @@ import { PluginRuntimeRegistry, type LoadedPlugin, } from "@/plugins/runtime"; +import type { PluginContributionDto } from "@/domain"; import { PluginLayoutSelectorSection, listPluginLayoutChoices } from "./PluginLayoutSelectorSection"; function stubPlugin(pluginId: string, displayName: string, layoutType: string): LoadedPlugin { @@ -66,4 +67,32 @@ describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => { fireEvent.click(screen.getByRole("menuitem", { name: /One layout/ })); expect(selected).toBe("dev.acme.one.layout"); }); + + it("skips malformed layout contributions instead of throwing during sorting", () => { + const registry = new PluginRuntimeRegistry(); + const contributes = { + menus: [], + menuItems: [], + layouts: [ + { type: "dev.acme.good", label: "Good layout", component: "X" }, + { type: "dev.acme.no-label", label: undefined, component: "X" }, + { type: undefined, label: "No type", component: "X" }, + ] as unknown as PluginContributionDto["layouts"], + mcpServers: [], + }; + registry.add({ + pluginId: "dev.acme.bad", + displayName: undefined as unknown as string, + contributes, + commands: new PluginCommandRegistry("dev.acme.bad", new Set()), + layouts: new PluginLayoutRegistry("dev.acme.bad", new Set()), + menu: new PluginMenuRegistry("dev.acme.bad"), + dispose: async () => {}, + }); + + const choices = listPluginLayoutChoices(registry); + expect(choices).toHaveLength(1); + expect(choices[0].layout.label).toBe("Good layout"); + expect(choices[0].pluginDisplayName).toBe("dev.acme.bad"); + }); }); diff --git a/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx b/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx index fb740e5..7e364ee 100644 --- a/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx +++ b/frontend/src/features/plugins/PluginLayoutSelectorSection.tsx @@ -26,15 +26,44 @@ export interface PluginLayoutChoice { layout: PluginLayoutContribution; } +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function finiteOrder(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function compareText(a: unknown, b: unknown): number { + return String(a ?? "").localeCompare(String(b ?? "")); +} + export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): PluginLayoutChoice[] { return registry .layoutContributions() - .map(({ pluginId, pluginDisplayName, layout }) => ({ pluginId, pluginDisplayName, layout })) + .flatMap(({ pluginId, pluginDisplayName, layout }) => { + const type = nonEmptyString(layout.type); + const label = nonEmptyString(layout.label); + if (!type || !label) return []; + return [ + { + pluginId, + pluginDisplayName: nonEmptyString(pluginDisplayName) ?? pluginId, + layout: { + ...layout, + type, + label, + order: finiteOrder(layout.order), + icon: nonEmptyString(layout.icon), + }, + }, + ]; + }) .sort( (a, b) => (a.layout.order ?? 0) - (b.layout.order ?? 0) || - a.pluginDisplayName.localeCompare(b.pluginDisplayName) || - a.layout.label.localeCompare(b.layout.label), + compareText(a.pluginDisplayName, b.pluginDisplayName) || + compareText(a.layout.label, b.layout.label), ); } diff --git a/frontend/src/features/plugins/menus.test.ts b/frontend/src/features/plugins/menus.test.ts index 0cfcae5..1bc7fe7 100644 --- a/frontend/src/features/plugins/menus.test.ts +++ b/frontend/src/features/plugins/menus.test.ts @@ -59,6 +59,23 @@ describe("resolveTopLevelMenus", () => { 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 }, + { 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", () => { @@ -137,6 +154,36 @@ describe("resolveMenuItems", () => { 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", + }, + { + 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; diff --git a/frontend/src/features/plugins/menus.ts b/frontend/src/features/plugins/menus.ts index 32253dd..61b5410 100644 --- a/frontend/src/features/plugins/menus.ts +++ b/frontend/src/features/plugins/menus.ts @@ -18,6 +18,24 @@ export interface ResolvedTopLevelMenu { order: number; } +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function finiteOrder(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function compareText(a: unknown, b: unknown): number { + return String(a ?? "").localeCompare(String(b ?? "")); +} + +function targetMatches(actual: unknown, expected: MenuTargetId): actual is MenuTargetId { + if (actual === expected) return true; + if (typeof actual !== "string") return false; + return expected.startsWith("plugin:") && actual === expected.slice("plugin:".length); +} + /** * Top-level plugin menus, sorted by `order` then plugin display name then * label (carnet §7.1) — rendered between `Panneaux` and `Paramètres`. @@ -25,19 +43,26 @@ export interface ResolvedTopLevelMenu { export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedTopLevelMenu[] { return registry .topLevelMenus() - .map(({ pluginId, pluginDisplayName, menu }) => ({ - id: `plugin:${menu.id}` as const, - pluginId, - pluginDisplayName, - label: menu.label, - icon: menu.icon, - order: menu.order ?? 0, - })) + .flatMap(({ pluginId, pluginDisplayName, menu }) => { + const id = nonEmptyString(menu.id); + const label = nonEmptyString(menu.label); + if (!id || !label) return []; + return [ + { + id: `plugin:${id}` as const, + pluginId, + pluginDisplayName, + label, + icon: nonEmptyString(menu.icon), + order: finiteOrder(menu.order), + }, + ]; + }) .sort( (a, b) => a.order - b.order || - a.pluginDisplayName.localeCompare(b.pluginDisplayName) || - a.label.localeCompare(b.label), + compareText(a.pluginDisplayName, b.pluginDisplayName) || + compareText(a.label, b.label), ); } @@ -55,27 +80,33 @@ export function resolveMenuItems( ): ResolvedPluginMenuItem[] { return registry .menuItems() - .filter(({ item }) => item.targetMenuId === targetMenuId) - .map(({ pluginId, pluginDisplayName, item }) => { + .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); + if (!id || !label || !command) return []; const result = evaluateWhen(item.when, whenCtx); - return { - id: item.id, - pluginId, - pluginDisplayName, - targetMenuId: item.targetMenuId, - label: item.label, - command: item.command, - enabled: result.ok ? result.value : false, - disabledReason: result.ok ? undefined : result.reason, - groupLabel: pluginDisplayName, - order: item.order ?? 0, - iconUrl: item.icon, - } satisfies ResolvedPluginMenuItem; + return [ + { + id, + pluginId, + pluginDisplayName: nonEmptyString(pluginDisplayName) ?? pluginId, + targetMenuId, + label, + command, + enabled: result.ok ? result.value : false, + disabledReason: result.ok ? undefined : result.reason, + groupLabel: nonEmptyString(pluginDisplayName) ?? pluginId, + order: finiteOrder(item.order), + iconUrl: nonEmptyString(item.icon), + } satisfies ResolvedPluginMenuItem, + ]; }) .sort( (a, b) => a.order - b.order || - a.pluginDisplayName.localeCompare(b.pluginDisplayName) || - a.label.localeCompare(b.label), + compareText(a.pluginDisplayName, b.pluginDisplayName) || + compareText(a.label, b.label), ); } diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index db1dae0..9a221e3 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -158,6 +158,43 @@ describe("loadPlugins", () => { expect(registry.get("com.example.hello-plugin")).toBeUndefined(); }); + it("loads the hello-plugin contribution shape with omitted optional arrays", async () => { + const bundle = dataUrl(` + export function activate(ctx) { + ctx.commands.registerCommand("hello-plugin.sayHello", () => { + globalThis.__helloArchiveCommandRan = true; + }); + } + `); + const { registry, failures } = await loadPlugins( + [ + entry({ + id: "com.example.hello-plugin", + displayName: "Hello Plugin", + bundleUrl: bundle, + contributes: { + 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", + }, + ], + } as unknown as PluginContributionDto, + }), + ], + gateways, + ); + + expect(failures).toEqual([]); + expect(registry.get("com.example.hello-plugin")?.contributes.layouts).toEqual([]); + expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]); + await registry.runCommand("com.example.hello-plugin", "hello-plugin.sayHello"); + expect((globalThis as Record).__helloArchiveCommandRan).toBe(true); + }); + it("calls dispose() on removal (best-effort)", async () => { const bundle = dataUrl(` export function activate(ctx) { diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 48f8adb..6c7a9ca 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -15,7 +15,7 @@ * collected, never thrown past `loadPlugins`. */ -import type { PluginRuntimePlugin } from "@/domain"; +import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain"; import { PluginCommandRegistry, PluginLayoutRegistry, @@ -101,6 +101,20 @@ function createCommandContext(commands: PluginCommandRegistry): PluginCommandCon }; } +function arrayOrEmpty(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto { + const contributes = entry.contributes as Partial | null | undefined; + return { + menus: arrayOrEmpty(contributes?.menus), + menuItems: arrayOrEmpty(contributes?.menuItems), + layouts: arrayOrEmpty(contributes?.layouts), + mcpServers: arrayOrEmpty(contributes?.mcpServers), + }; +} + async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise { try { await activation?.dispose?.(); @@ -135,8 +149,9 @@ async function loadOne( }; } - const declaredCommandIds = new Set(entry.contributes.menuItems.map((item) => item.command)); - const declaredLayoutTypes = new Set(entry.contributes.layouts.map((layout) => layout.type)); + const contributes = normalizeContributes(entry); + const declaredCommandIds = new Set(contributes.menuItems.map((item) => item.command)); + const declaredLayoutTypes = new Set(contributes.layouts.map((layout) => layout.type)); const commands = new PluginCommandRegistry(entry.id, declaredCommandIds); const layouts = new PluginLayoutRegistry(entry.id, declaredLayoutTypes); const menu = new PluginMenuRegistry(entry.id); @@ -159,7 +174,7 @@ async function loadOne( const plugin: LoadedPlugin = { pluginId: entry.id, displayName: entry.displayName, - contributes: entry.contributes, + contributes, commands, layouts, menu, diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts index 3d444ae..a1afadf 100644 --- a/frontend/src/plugins/runtime/registry.ts +++ b/frontend/src/plugins/runtime/registry.ts @@ -146,6 +146,10 @@ export interface LoadedPlugin { dispose(): Promise; } +function arrayOrEmpty(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + /** * Aggregate, session-scoped registry every loaded plugin's contributions land * in. `PluginRuntimeRegistry` itself never imports bundles (see `loader.ts`); @@ -180,7 +184,7 @@ export class PluginRuntimeRegistry { /** All top-level menu contributions across every loaded plugin. */ topLevelMenus(): Array<{ pluginId: string; pluginDisplayName: string; menu: PluginTopLevelMenuContribution }> { return this.list().flatMap((p) => - p.contributes.menus.map((menu) => ({ + arrayOrEmpty(p.contributes.menus).map((menu) => ({ pluginId: p.pluginId, pluginDisplayName: p.displayName, menu, @@ -191,7 +195,7 @@ export class PluginRuntimeRegistry { /** All menu-item contributions across every loaded plugin. */ menuItems(): Array<{ pluginId: string; pluginDisplayName: string; item: PluginMenuItemContribution }> { return this.list().flatMap((p) => - p.contributes.menuItems.map((item) => ({ + arrayOrEmpty(p.contributes.menuItems).map((item) => ({ pluginId: p.pluginId, pluginDisplayName: p.displayName, item, @@ -214,7 +218,7 @@ export class PluginRuntimeRegistry { layout: PluginLayoutContribution; }> { return this.list().flatMap((p) => - p.contributes.layouts.map((layout) => ({ + arrayOrEmpty(p.contributes.layouts).map((layout) => ({ pluginId: p.pluginId, pluginDisplayName: p.displayName, layout,