/** * 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 & { 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).__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).__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) { 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).__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).__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).__helloCommandRan).toBe(true); await registry.remove("com.example.hello-plugin"); expect(registry.get("com.example.hello-plugin")).toBeUndefined(); }); it("does not inject the public plugin services facade without the tooling capability", async () => { const bundle = dataUrl(` export function activate(ctx) { globalThis.__servicesWithoutTooling = ctx.services; } `); const { failures } = await loadPlugins( [entry({ id: "dev.acme.no-services", displayName: "No Services", bundleUrl: bundle })], gateways, ); expect(failures).toEqual([]); expect((globalThis as Record).__servicesWithoutTooling).toBeUndefined(); }); it("injects the public plugin services facade for plugins declaring tooling", async () => { const bundle = dataUrl(` export function activate(ctx) { globalThis.__serviceKeys = Object.keys(ctx.services).sort(); globalThis.__workspaceServiceKeys = Object.keys(ctx.services.workspace).sort(); globalThis.__taskServiceKeys = Object.keys(ctx.services.tasks).sort(); globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort(); } `); const { failures } = await loadPlugins( [ entry({ id: "dev.acme.services", displayName: "Services", capabilities: ["tooling"], bundleUrl: bundle, }), ], gateways, ); expect(failures).toEqual([]); expect((globalThis as Record).__serviceKeys).toEqual([ "tasks", "terminal", "workspace", ]); expect((globalThis as Record).__workspaceServiceKeys).toEqual([ "getCurrentProject", "getProjectRoot", "readProjectContext", "updateProjectContext", ]); expect((globalThis as Record).__taskServiceKeys).toEqual([ "attachOutput", "cancel", "getStatus", "list", "retry", ]); expect((globalThis as Record).__terminalServiceKeys).toEqual([ "close", "open", "reattach", ]); }); it("loads the hello-plugin command and layout contribution shape", async () => { const bundle = dataUrl(` export function activate(ctx) { ctx.commands.registerCommand("hello-plugin", () => { globalThis.__helloArchiveCommandRan = true; }); ctx.layouts.register({ type: "hello-plugin.hello-world", component: () => "hello-world", }); } `); const { registry, failures } = await loadPlugins( [ entry({ id: "com.example.hello-plugin", displayName: "Hello Plugin", bundleUrl: bundle, contributes: { menus: [{ id: "hello-plugin.menu", label: "Hello Plugin", topLevel: true }], menuItems: [ { id: "hello-plugin.command.item", targetMenuId: "hello-plugin.menu", label: "hello-plugin", command: "hello-plugin", }, ], layouts: [ { type: "hello-plugin.hello-world", label: "hello-world", component: "hello-world", }, ], } as unknown as PluginContributionDto, }), ], gateways, ); expect(failures).toEqual([]); expect(registry.get("com.example.hello-plugin")?.contributes.layouts).toEqual([ { type: "hello-plugin.hello-world", label: "hello-world", component: "hello-world", }, ]); expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]); await registry.runCommand("com.example.hello-plugin", "hello-plugin"); expect((globalThis as Record).__helloArchiveCommandRan).toBe(true); const Layout = registry.layoutComponent( "com.example.hello-plugin", "hello-plugin.hello-world", ); expect(Layout).toBeDefined(); expect((Layout as unknown as () => string)()).toBe("hello-world"); }); it("confines a malformed runtime catalog entry and still loads healthy plugins", async () => { const bundle = dataUrl(` export function activate(ctx) { globalThis.__healthyPluginActivated = ctx.pluginId; } `); const { registry, failures } = await loadPlugins( [ null, entry({ id: "dev.acme.healthy", displayName: "Healthy", bundleUrl: bundle, contributes: { menus: [null], menuItems: [null, { id: "x", targetMenuId: "panels", label: "X" }], layouts: [null, { label: "Missing type" }], } as unknown as PluginContributionDto, }), ] as unknown as PluginRuntimePlugin[], gateways, ); expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.healthy"]); expect(failures).toEqual([ { pluginId: "", reason: expect.stringContaining("bundle URL") }, ]); expect((globalThis as Record).__healthyPluginActivated).toBe( "dev.acme.healthy", ); }); 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).__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("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).__partialActivationDisposed).toBe( "dev.acme.partial", ); }); it("collects a failure when plugin activation does not settle", async () => { const bundle = dataUrl(` export function activate() { return new Promise(() => {}); } `); const { registry, failures } = await loadPlugins( [entry({ id: "dev.acme.hung", displayName: "Hung", bundleUrl: bundle })], gateways, { timeoutMs: 10 }, ); expect(registry.list()).toEqual([]); expect(failures).toEqual([ { pluginId: "dev.acme.hung", reason: "activating plugin timed out after 10 ms", }, ]); }); 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([]); }); });