Files
IdeaSDK/frontend/src/features/plugins/plugins.test.tsx
Blomios efaeb31a38 docs(tickets): consigne le cycle #120 fix CORS + décision Git et états (#120)
- carnet.md: enregistre la livraison CORS et la décision Git du 2026-08-01
- issue.md + index.json: mise à jour version/timestamp
- plugins.test.tsx: tests d'intégration frontend liés
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 19:12:39 +02:00

234 lines
9.3 KiB
TypeScript

/**
* 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(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider value={runtimeValue}>
<PluginsPanel />
</PluginRuntimeProvider>
</DIProvider>,
),
};
}
function renderPanelWithLiveRuntime(plugin: MockPluginGateway, system = new MockSystemGateway()) {
const gateways = { plugin, system } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
<PluginsPanel />
</PluginRuntimeProvider>
</DIProvider>,
);
}
class InvalidInstallPluginGateway extends MockPluginGateway {
async installFromDirectory(path: string): Promise<PluginInstallResult> {
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<PluginRuntimeContributionCatalog> {
throw { code: "INVALID", message: "runtime catalog failed" };
}
}
class BackendShapedReviewPluginGateway extends MockPluginGateway {
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
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("<runtime-catalog>")).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();
});
});