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,102 @@
/**
* F2 — `PluginsPanel` (ticket #43, carnet §10 F2 acceptance criteria): install
* from archive/directory with pre-install review, full-trust mention visible,
* enable/disable/uninstall flows, and `restartRequired` rendered.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { PluginsPanel } from "./PluginsPanel";
function renderPanel(plugin?: MockPluginGateway, system?: MockSystemGateway) {
const p = plugin ?? new MockPluginGateway();
const s = system ?? new MockSystemGateway();
const gateways = { plugin: p, system: s } as unknown as Gateways;
return {
plugin: p,
system: s,
...render(
<DIProvider gateways={gateways}>
<PluginsPanel />
</DIProvider>,
),
};
}
describe("PluginsPanel", () => {
it("shows an empty state with no plugins installed", async () => {
renderPanel();
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
});
it("installs from an archive via the review dialog, mentioning full-trust", async () => {
renderPanel();
await screen.findByText("Aucun plugin installé.");
fireEvent.click(screen.getByRole("button", { name: "Installer depuis une archive…" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText(/full-trust/)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Installer" }));
// The installed plugin now shows in the list, restart required (carnet §1.4).
expect(await screen.findByText("mock-plugin")).toBeTruthy();
expect(screen.getByText("Redémarrage requis")).toBeTruthy();
expect(screen.getByText("Activé")).toBeTruthy();
});
it("disables an installed plugin after confirmation", async () => {
const plugin = new MockPluginGateway();
plugin._seedPlugin({
id: "dev.acme.one",
displayName: "Acme One",
version: "1.0.0",
sourceKind: "archive",
lifecycleState: "enabled",
enabled: true,
pendingUninstall: false,
restartRequired: false,
trustLevel: "full",
contributionSummary: { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 },
});
renderPanel(plugin);
await screen.findByText("Acme One");
fireEvent.click(screen.getByRole("button", { name: "Désactiver" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Désactiver" }));
await waitFor(() => expect(screen.getByText("Désactivé")).toBeTruthy());
});
it("uninstalls an installed plugin after confirmation", async () => {
const plugin = new MockPluginGateway();
plugin._seedPlugin({
id: "dev.acme.two",
displayName: "Acme Two",
version: "1.0.0",
sourceKind: "directory",
lifecycleState: "enabled",
enabled: true,
pendingUninstall: false,
restartRequired: false,
trustLevel: "full",
contributionSummary: { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 },
});
renderPanel(plugin);
await screen.findByText("Acme Two");
fireEvent.click(screen.getByRole("button", { name: "Désinstaller" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Désinstaller" }));
await waitFor(() => expect(screen.queryByText("Acme Two")).toBeNull());
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
});
});