- 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>
234 lines
9.3 KiB
TypeScript
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();
|
|
});
|
|
});
|