fix(plugins): support export default { activate }, cleanup partiel et erreurs visibles

Le loader accepte désormais aussi la forme export default { activate }
en plus de l'export nommé, les subscriptions partiellement établies
sont nettoyées en cas d'échec d'activation, et une erreur de
runtime-catalog est remontée visuellement dans PluginsPanel avec un
badge Invalide/Isolé sur les plugins concernés (#116/#120).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:05:57 +02:00
parent 50b71f4ece
commit 46086af026
5 changed files with 144 additions and 7 deletions

View File

@ -7,6 +7,7 @@ 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, PluginRuntimeContributionCatalog } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { PluginRuntimeRegistry } from "@/plugins/runtime";
@ -38,6 +39,44 @@ function renderPanel(
};
}
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" };
}
}
describe("PluginsPanel", () => {
it("shows an empty state with no plugins installed", async () => {
renderPanel();
@ -57,6 +96,15 @@ describe("PluginsPanel", () => {
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é.");
@ -74,6 +122,21 @@ describe("PluginsPanel", () => {
expect(screen.getByText("Activé")).toBeTruthy();
});
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({