/** * 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 { PluginInstallResult, PluginReview, PluginRuntimeContributionCatalog } from "@/domain"; import type { Gateways, ReviewPluginPackageInput } from "@/ports"; import { DIProvider } from "@/app/di"; import { PluginRuntimeRegistry } from "@/plugins/runtime"; import { PluginsPanel } from "./PluginsPanel"; import { PluginRuntimeProvider, type PluginRuntimeContextValue } from "./PluginRuntimeProvider"; function renderPanel( plugin?: MockPluginGateway, system?: MockSystemGateway, runtimeValue: PluginRuntimeContextValue = { registry: new PluginRuntimeRegistry(), failures: [], loading: false, }, ) { 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( , ), }; } function renderPanelWithLiveRuntime(plugin: MockPluginGateway, system = new MockSystemGateway()) { const gateways = { plugin, system } as unknown as Gateways; return render( , ); } class InvalidInstallPluginGateway extends MockPluginGateway { async installFromDirectory(path: string): Promise { const plugin = { id: "dev.idea.fixtures.missing-main", displayName: "Missing Main Plugin", version: "0.1.0", sourceKind: "directory" as const, sourceLabel: path, lifecycleState: "invalid" as const, enabled: false, pendingUninstall: false, restartRequired: false, trustLevel: "full" as const, contributionSummary: { topLevelMenus: 1, menuItems: 1, layouts: 0, mcpServers: 0 }, error: "plugin main asset is missing: dist/index.js", }; this._seedPlugin(plugin); return { plugin, restartRequired: false }; } } class FailingRuntimeCatalogPluginGateway extends MockPluginGateway { async listRuntimeContributions(): Promise { throw { code: "INVALID", message: "runtime catalog failed" }; } } class BackendShapedReviewPluginGateway extends MockPluginGateway { async reviewPackage(input: ReviewPluginPackageInput): Promise { const label = input.path.split("/").pop() ?? input.path; return { id: "dev.acme.gitgraph", displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""), publisher: "Acme", version: "1.2.3", description: "Graph", sourceKind: input.sourceKind, sourceLabel: input.path, contentHash: "abc", trustLevel: "full", contributionSummary: { topLevelMenus: 1, menuItems: 2, layouts: 3, mcpServers: 4 }, contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, } as unknown as PluginReview; } } describe("PluginsPanel", () => { it("shows an empty state with no plugins installed", async () => { renderPanel(); expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy(); }); it("keeps the Plugins panel rendered when a runtime bundle fails to load", async () => { renderPanel(undefined, undefined, { registry: new PluginRuntimeRegistry(), failures: [{ pluginId: "com.example.hello-plugin", reason: "Cannot use import statement outside a module" }], loading: false, }); expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy(); expect(screen.getByText("Certains plugins installés n'ont pas pu être chargés.")).toBeTruthy(); expect(screen.getByText("com.example.hello-plugin")).toBeTruthy(); expect(screen.getByText(/Cannot use import statement outside a module/)).toBeTruthy(); }); it("keeps the Plugins panel rendered when the runtime catalog fetch fails", async () => { renderPanelWithLiveRuntime(new FailingRuntimeCatalogPluginGateway()); expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy(); expect(screen.getByText("Certains plugins installés n'ont pas pu être chargés.")).toBeTruthy(); expect(screen.getByText("")).toBeTruthy(); expect(screen.getByText(/runtime catalog failed/)).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("keeps install, uninstall and reinstall stable with backend-shaped reviews", async () => { renderPanel(new BackendShapedReviewPluginGateway()); await screen.findByText("Aucun plugin installé."); fireEvent.click(screen.getByRole("button", { name: "Installer depuis une archive…" })); let dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText(/full-trust/)).toBeTruthy(); fireEvent.click(within(dialog).getByRole("button", { name: "Installer" })); expect(await screen.findByText("mock-plugin")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Désinstaller" })); dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByRole("button", { name: "Désinstaller" })); expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Installer depuis une archive…" })); dialog = await screen.findByRole("dialog"); expect(within(dialog).getByRole("button", { name: "Installer" })).toBeTruthy(); fireEvent.click(within(dialog).getByRole("button", { name: "Installer" })); expect(await screen.findByText("mock-plugin")).toBeTruthy(); expect(screen.queryByText("INTERFACE INTERROMPUE")).toBeNull(); }); it("renders an invalid plugin returned after install as isolated, without replacing the panel", async () => { renderPanel(new InvalidInstallPluginGateway()); await screen.findByText("Aucun plugin installé."); fireEvent.click(screen.getByRole("button", { name: "Installer depuis un dossier…" })); const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByRole("button", { name: "Installer" })); expect(await screen.findByText("Missing Main Plugin")).toBeTruthy(); expect(screen.getByText("Invalide")).toBeTruthy(); expect(screen.getByText("plugin main asset is missing: dist/index.js")).toBeTruthy(); expect(screen.getByRole("button", { name: "Isolé" }).hasAttribute("disabled")).toBe(true); expect(screen.queryByRole("button", { name: "Activer" })).toBeNull(); }); 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(); }); });