Files
IdeaSDK/frontend/src/plugins/runtime/loader.test.ts
Blomios aa850374ce fix(plugins): isole les contributions plugin en erreur et durcit menus.ts
Le chargement de l'archive hello-plugin (build/hello-plugin-0.1.0.zip)
vidait la fenêtre principale : une contribution plugin fautive
remontait jusqu'au rendu global au lieu de rester locale à la cellule.
Ajoute un boundary local dans PluginLayoutCellView/PluginLayoutSelectorSection
et durcit menus.ts/loader.ts/registry.ts contre les entrées de menu ou
contributions malformées.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 09:08:14 +02:00

239 lines
7.8 KiB
TypeScript

/**
* F1 — plugin bootstrap loader tests (ticket #43, carnet §6/§10 F1 acceptance
* criteria: "plugins mock chargés, registration refusée si non déclarée,
* dispose appelé, disabled absent du bootstrap").
*
* Bundles are loaded via a real dynamic `import()` of `data:` URLs (supported
* by Node's ESM loader, which Vitest runs on) so the loader is exercised
* exactly as it runs against the `idea-plugin://...` protocol in production —
* no mocking of `import()` itself.
*/
import { describe, expect, it } from "vitest";
import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
import { loadPlugins } from "./loader";
import type { PluginGatewaySet } from "./loader";
function dataUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
}
function emptyContributes(): PluginContributionDto {
return { menus: [], menuItems: [], layouts: [], mcpServers: [] };
}
const gateways = {} as PluginGatewaySet;
function entry(overrides: Partial<PluginRuntimePlugin> & { bundleUrl: string }): PluginRuntimePlugin {
return {
id: "mock.plugin",
displayName: "Mock Plugin",
version: "1.0.0",
contentHash: "abc123",
contributes: emptyContributes(),
...overrides,
};
}
describe("loadPlugins", () => {
it("loads a well-formed plugin bundle and calls activate(ctx)", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__activatedWith = ctx.pluginId;
return {};
}
`);
const { registry, failures } = await loadPlugins(
[entry({ id: "dev.acme.one", displayName: "One", bundleUrl: bundle })],
gateways,
);
expect(failures).toEqual([]);
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.one"]);
expect((globalThis as Record<string, unknown>).__activatedWith).toBe("dev.acme.one");
});
it("refuses to register a command not declared in the manifest", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
let caught = null;
try {
ctx.commands.register("dev.acme.undeclared.cmd", () => {});
} catch (e) {
caught = String(e);
}
globalThis.__registerError = caught;
return {};
}
`);
const { failures } = await loadPlugins(
[
entry({
id: "dev.acme.two",
displayName: "Two",
bundleUrl: bundle,
contributes: emptyContributes(),
}),
],
gateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__registerError).toMatch(
/not declared by any menu item/,
);
});
it("accepts registering a command declared via a menu item contribution", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.commands.register("dev.acme.three.open", () => {
globalThis.__ranCommand = true;
});
return {};
}
`);
const { registry, failures } = await loadPlugins(
[
entry({
id: "dev.acme.three",
displayName: "Three",
bundleUrl: bundle,
contributes: {
...emptyContributes(),
menuItems: [
{
id: "dev.acme.three.item",
targetMenuId: "panels",
label: "Open",
command: "dev.acme.three.open",
},
],
},
}),
],
gateways,
);
expect(failures).toEqual([]);
await registry.runCommand("dev.acme.three", "dev.acme.three.open");
expect((globalThis as Record<string, unknown>).__ranCommand).toBe(true);
});
it("loads plugins built against the public SDK context shape", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.logger.info("hello");
const disposable = ctx.commands.registerCommand("hello-plugin.sayHello", () => {
globalThis.__helloCommandRan = true;
return "Hello from IdeA";
});
ctx.subscriptions.push(disposable);
}
`);
const { registry, failures } = await loadPlugins(
[
entry({
id: "com.example.hello-plugin",
displayName: "Hello Plugin",
bundleUrl: bundle,
contributes: {
...emptyContributes(),
menuItems: [
{
id: "hello-plugin.sayHello.item",
targetMenuId: "plugin:hello-plugin.menu",
label: "Say Hello",
command: "hello-plugin.sayHello",
},
],
},
}),
],
gateways,
);
expect(failures).toEqual([]);
await registry.runCommand("com.example.hello-plugin", "hello-plugin.sayHello");
expect((globalThis as Record<string, unknown>).__helloCommandRan).toBe(true);
await registry.remove("com.example.hello-plugin");
expect(registry.get("com.example.hello-plugin")).toBeUndefined();
});
it("loads the hello-plugin contribution shape with omitted optional arrays", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.commands.registerCommand("hello-plugin.sayHello", () => {
globalThis.__helloArchiveCommandRan = true;
});
}
`);
const { registry, failures } = await loadPlugins(
[
entry({
id: "com.example.hello-plugin",
displayName: "Hello Plugin",
bundleUrl: bundle,
contributes: {
menus: [{ id: "hello-plugin.menu", label: "Hello", topLevel: true }],
menuItems: [
{
id: "hello-plugin.sayHello.item",
targetMenuId: "hello-plugin.menu",
label: "Say Hello",
command: "hello-plugin.sayHello",
},
],
} as unknown as PluginContributionDto,
}),
],
gateways,
);
expect(failures).toEqual([]);
expect(registry.get("com.example.hello-plugin")?.contributes.layouts).toEqual([]);
expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]);
await registry.runCommand("com.example.hello-plugin", "hello-plugin.sayHello");
expect((globalThis as Record<string, unknown>).__helloArchiveCommandRan).toBe(true);
});
it("calls dispose() on removal (best-effort)", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
return {
dispose: () => {
globalThis.__disposed = ctx.pluginId;
},
};
}
`);
const { registry } = await loadPlugins(
[entry({ id: "dev.acme.four", displayName: "Four", bundleUrl: bundle })],
gateways,
);
await registry.remove("dev.acme.four");
expect((globalThis as Record<string, unknown>).__disposed).toBe("dev.acme.four");
expect(registry.get("dev.acme.four")).toBeUndefined();
});
it("collects a failure instead of throwing when a bundle has no activate()", async () => {
const bundle = dataUrl(`export const notAPlugin = true;`);
const { registry, failures } = await loadPlugins(
[entry({ id: "dev.acme.five", displayName: "Five", bundleUrl: bundle })],
gateways,
);
expect(registry.list()).toEqual([]);
expect(failures).toEqual([
{ pluginId: "dev.acme.five", reason: expect.stringContaining("activate") },
]);
});
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
// loader itself has nothing more to filter — an empty catalog loads
// nothing.
const { registry, failures } = await loadPlugins([], gateways);
expect(registry.list()).toEqual([]);
expect(failures).toEqual([]);
});
});