feat(frontend): système de plugins — runtime, menus, layouts custom (#43)

Lots F1-F4 : runtime de chargement/registre plugin, extension des menus
existants, panneau de gestion des plugins, types de layout custom
(sélecteur, fallback, cellule dédiée) branchés sur le port plugin.
Suite npm typecheck/test verte (947/947).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:37:11 +02:00
parent bb35641715
commit ac726d075e
41 changed files with 3245 additions and 24 deletions

View File

@ -0,0 +1,69 @@
/**
* F4 — `PluginLayoutSelectorSection` (carnet §10 F4 acceptance criteria:
* "layout disabled non proposé comme nouveau choix").
*/
import { describe, expect, it } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
} from "@/plugins/runtime";
import { PluginLayoutSelectorSection, listPluginLayoutChoices } from "./PluginLayoutSelectorSection";
function stubPlugin(pluginId: string, displayName: string, layoutType: string): LoadedPlugin {
const contributes = {
menus: [],
menuItems: [],
layouts: [{ type: layoutType, label: `${displayName} layout`, component: "X" }],
mcpServers: [],
};
return {
pluginId,
displayName,
contributes,
commands: new PluginCommandRegistry(pluginId, new Set()),
layouts: new PluginLayoutRegistry(pluginId, new Set([layoutType])),
menu: new PluginMenuRegistry(pluginId),
dispose: async () => {},
};
}
describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => {
it("only lists loaded (enabled) plugins' layouts — a disabled plugin is never in the registry", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin("dev.acme.one", "One", "dev.acme.one.layout"));
// "dev.acme.two" is disabled ⇒ never loaded ⇒ never added to the registry
// (carnet §1.3) — nothing to filter here beyond what's already loaded.
const choices = listPluginLayoutChoices(registry);
expect(choices).toHaveLength(1);
expect(choices[0].pluginId).toBe("dev.acme.one");
});
it("renders nothing when there are no plugin layouts", () => {
const { container } = render(
<PluginLayoutSelectorSection registry={new PluginRuntimeRegistry()} onSelect={() => {}} />,
);
expect(container.innerHTML).toBe("");
});
it("calls onSelect with the chosen plugin layout", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin("dev.acme.one", "One", "dev.acme.one.layout"));
let selected: string | null = null;
render(
<PluginLayoutSelectorSection
registry={registry}
onSelect={(choice) => {
selected = choice.layout.type;
}}
/>,
);
fireEvent.click(screen.getByRole("menuitem", { name: /One layout/ }));
expect(selected).toBe("dev.acme.one.layout");
});
});