feat(frontend): système de plugins — runtime, menus, layouts custom (#43)
Lots F1-F4 : runtime de chargement/registre plugin, extension des menus existants, panneau de gestion des plugins, types de layout custom (sélecteur, fallback, cellule dédiée) branchés sur le port plugin. Suite npm typecheck/test verte (947/947). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
160
frontend/src/plugins/runtime/loader.test.ts
Normal file
160
frontend/src/plugins/runtime/loader.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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("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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user