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,113 @@
/**
* 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 { render, screen, fireEvent } from "@testing-library/react";
import type { CustomPluginLayoutCell } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { createMockGateways } from "@/adapters/mock";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
type PluginLayoutProps,
} from "@/plugins/runtime";
import { PluginRuntimeProvider } from "./PluginRuntimeProvider";
import { PluginLayoutCellView } from "./PluginLayoutCellView";
function cell(overrides: Partial<CustomPluginLayoutCell> = {}): CustomPluginLayoutCell {
return {
id: "leaf-1",
pluginId: "dev.acme.gitgraph",
layoutType: "dev.acme.gitgraph.layout",
state: { commits: 3 },
...overrides,
};
}
function MockLayoutComponent(props: PluginLayoutProps) {
return (
<div>
<p data-testid="state">{JSON.stringify(props.state)}</p>
<button onClick={() => props.setState({ commits: 4 })}>bump</button>
</div>
);
}
function stubPlugin(): LoadedPlugin {
const contributes = {
menus: [],
menuItems: [],
layouts: [{ type: "dev.acme.gitgraph.layout", label: "Git Graph", component: "GitGraphLayout" }],
mcpServers: [],
};
const layouts = new PluginLayoutRegistry("dev.acme.gitgraph", new Set(["dev.acme.gitgraph.layout"]));
layouts.register({ type: "dev.acme.gitgraph.layout", component: MockLayoutComponent });
return {
pluginId: "dev.acme.gitgraph",
displayName: "Git Graph",
contributes,
commands: new PluginCommandRegistry("dev.acme.gitgraph", new Set()),
layouts,
menu: new PluginMenuRegistry("dev.acme.gitgraph"),
dispose: async () => {},
};
}
function renderCell(
registry: PluginRuntimeRegistry,
props: Partial<{ cell: CustomPluginLayoutCell; onStateChange: (s: unknown) => void }> = {},
) {
const gateways: Gateways = createMockGateways();
return render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
<PluginLayoutCellView
projectId="proj-1"
cell={props.cell ?? cell()}
onStateChange={props.onStateChange ?? (() => {})}
onOpenPlugins={() => {}}
onChooseAnotherLayout={() => {}}
/>
</PluginRuntimeProvider>
</DIProvider>,
);
}
describe("PluginLayoutCellView", () => {
it("renders the mock registered component when the provider is loaded", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin());
renderCell(registry);
expect(screen.getByTestId("state").textContent).toBe(JSON.stringify({ commits: 3 }));
});
it("round-trips state through setState → onStateChange", () => {
const registry = new PluginRuntimeRegistry();
registry.add(stubPlugin());
let lastState: unknown = null;
renderCell(registry, {
onStateChange: (s) => {
lastState = s;
},
});
fireEvent.click(screen.getByRole("button", { name: "bump" }));
expect(lastState).toEqual({ commits: 4 });
});
it("renders the non-destructive fallback when the provider is not loaded, without mutating the cell", () => {
const registry = new PluginRuntimeRegistry(); // empty — provider not loaded
const testCell = cell();
renderCell(registry, { cell: testCell });
expect(screen.getByText("Layout indisponible")).toBeTruthy();
// The cell object itself is untouched — the domain identity/state survive.
expect(testCell).toEqual(cell());
});
});