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:
@ -38,6 +38,13 @@ const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = {
|
||||
|
||||
const PluginRuntimeContext = createContext<PluginRuntimeContextValue>(EMPTY_PLUGIN_RUNTIME);
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
interface PluginRuntimeProviderProps {
|
||||
children: ReactNode;
|
||||
/** Test/Storybook escape hatch — skips the gateway fetch and uses this value as-is. */
|
||||
@ -72,10 +79,19 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
if (cancelled) return;
|
||||
setValue({ registry: result.registry, failures: result.failures, loading: false });
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((e: unknown) => {
|
||||
// No plugin gateway / catalog fetch failed: run with zero plugins
|
||||
// rather than blocking the app (full-trust plugins are additive).
|
||||
if (!cancelled) setValue((prev) => ({ ...prev, loading: false }));
|
||||
if (!cancelled) {
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
failures: [
|
||||
...prev.failures,
|
||||
{ pluginId: "<runtime-catalog>", reason: describeError(e) },
|
||||
],
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@ -167,7 +167,11 @@ export function PluginsPanel() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{p.enabled ? (
|
||||
{p.lifecycleState === "invalid" ? (
|
||||
<Button size="sm" variant="ghost" disabled>
|
||||
Isolé
|
||||
</Button>
|
||||
) : p.enabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -52,6 +52,26 @@ describe("loadPlugins", () => {
|
||||
expect((globalThis as Record<string, unknown>).__activatedWith).toBe("dev.acme.one");
|
||||
});
|
||||
|
||||
it("loads an SDK-style default export containing activate(ctx)", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export default {
|
||||
activate(ctx) {
|
||||
globalThis.__defaultExportActivatedWith = ctx.pluginId;
|
||||
},
|
||||
};
|
||||
`);
|
||||
const { registry, failures } = await loadPlugins(
|
||||
[entry({ id: "com.example.hello-plugin", displayName: "Hello Plugin", bundleUrl: bundle })],
|
||||
gateways,
|
||||
);
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
expect(registry.list().map((p) => p.pluginId)).toEqual(["com.example.hello-plugin"]);
|
||||
expect((globalThis as Record<string, unknown>).__defaultExportActivatedWith).toBe(
|
||||
"com.example.hello-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to register a command not declared in the manifest", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
@ -259,6 +279,32 @@ describe("loadPlugins", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("disposes partial subscriptions when activation fails", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
ctx.subscriptions.push({
|
||||
dispose() {
|
||||
globalThis.__partialActivationDisposed = ctx.pluginId;
|
||||
},
|
||||
});
|
||||
throw new Error("activation failed");
|
||||
}
|
||||
`);
|
||||
|
||||
const { registry, failures } = await loadPlugins(
|
||||
[entry({ id: "dev.acme.partial", displayName: "Partial", bundleUrl: bundle })],
|
||||
gateways,
|
||||
);
|
||||
|
||||
expect(registry.list()).toEqual([]);
|
||||
expect(failures).toEqual([
|
||||
{ pluginId: "dev.acme.partial", reason: "activation failed" },
|
||||
]);
|
||||
expect((globalThis as Record<string, unknown>).__partialActivationDisposed).toBe(
|
||||
"dev.acme.partial",
|
||||
);
|
||||
});
|
||||
|
||||
it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => {
|
||||
// The backend contract (carnet §1.3) filters the catalog to
|
||||
// `enabled && !pendingUninstall` before the loader ever sees it; the
|
||||
|
||||
@ -81,6 +81,12 @@ function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveIdeaPluginModule(mod: unknown): IdeaPluginModule | null {
|
||||
if (isIdeaPluginModule(mod)) return mod;
|
||||
const defaultExport = objectOrEmpty(mod).default;
|
||||
return isIdeaPluginModule(defaultExport) ? defaultExport : null;
|
||||
}
|
||||
|
||||
function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
|
||||
const prefix = `[plugin:${safePluginId(entry)}]`;
|
||||
return {
|
||||
@ -169,6 +175,8 @@ async function loadOne(
|
||||
const pluginId = safePluginId(entry);
|
||||
const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
|
||||
const version = nonEmptyString(entryObject.version) ?? "";
|
||||
let activation: void | PluginActivation = undefined;
|
||||
const subscriptions: Disposable[] = [];
|
||||
try {
|
||||
// The bundle URL is a plugin-scoped, content-hashed local protocol URL
|
||||
// served by the backend (carnet §1.3) — never a disk path or arbitrary
|
||||
@ -178,8 +186,8 @@ async function loadOne(
|
||||
throw new Error("missing plugin bundle URL");
|
||||
}
|
||||
|
||||
const mod: unknown = await import(/* @vite-ignore */ bundleUrl);
|
||||
if (!isIdeaPluginModule(mod)) {
|
||||
const mod = resolveIdeaPluginModule(await import(/* @vite-ignore */ bundleUrl));
|
||||
if (!mod) {
|
||||
return {
|
||||
failure: {
|
||||
pluginId,
|
||||
@ -194,7 +202,6 @@ async function loadOne(
|
||||
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
|
||||
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
|
||||
const menu = new PluginMenuRegistry(pluginId);
|
||||
const subscriptions: Disposable[] = [];
|
||||
|
||||
const ctx: IdeaPluginContext = {
|
||||
pluginId,
|
||||
@ -208,7 +215,7 @@ async function loadOne(
|
||||
...gateways,
|
||||
};
|
||||
|
||||
const activation = await mod.activate(ctx);
|
||||
activation = await mod.activate(ctx);
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
pluginId,
|
||||
@ -225,6 +232,7 @@ async function loadOne(
|
||||
};
|
||||
return { plugin };
|
||||
} catch (e) {
|
||||
await disposeAll(subscriptions, activation);
|
||||
return {
|
||||
failure: {
|
||||
pluginId,
|
||||
|
||||
Reference in New Issue
Block a user