Files
IdeA/frontend/src/plugins/runtime/loader.test.ts
Blomios 561c50f6ed feat(backend,frontend): lot utilisateur #346 #347 #348 — exécution workspace plugin contrôlée par l'hôte, réordonnancement des onglets projet par glisser-déposer, Final restauré après stop d'agent CLI custom (QA verte)
#346 — capacités plugin SCM: le manifeste permissions.workspaceExec
(contrat SDK 8ff0f47) est appliqué côté hôte — domain PluginWorkspaceExec
Rule/Effect + audit sink, port WorkspaceToolRunner, adapter
LocalWorkspaceToolRunner sandboxé (concurrence 4, timeout, bornes
stdout/stderr, résolution bare executable sans shell), service
application + DTO backend + commandes app-tauri, gateway frontend
execTool exposée au runtime plugin — fin des lectures ad hoc du store
interne .git par les plugins.
#347 — onglets projet réordonnables par glisser-déposer (Tabs DnD,
onReorder, persistance de l'ordre via useProjects).
#348 — stop d'agent CLI custom: le message Final vert réapparaît au tour
suivant (override terminal stop armé correctement, reset de
l'interaction, refresh de la délégation requester).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 12:23:44 +02:00

806 lines
25 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",
pluginRoot: "/data/idea/plugins/installed/mock.plugin",
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 = { pluginId: ctx.pluginId, pluginRoot: ctx.pluginRoot };
return {};
}
`);
const { registry, failures } = await loadPlugins(
[entry({
id: "dev.acme.one",
displayName: "One",
bundleUrl: bundle,
pluginRoot: "/data/idea/plugins/installed/dev.acme.one",
})],
gateways,
);
expect(failures).toEqual([]);
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.one"]);
expect((globalThis as Record<string, unknown>).__activatedWith).toEqual({
pluginId: "dev.acme.one",
pluginRoot: "/data/idea/plugins/installed/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) {
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 command contribution/,
);
});
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("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<string, unknown>).__servicesWithoutTooling).toBeUndefined();
});
it("injects the public plugin services facade for plugins declaring tooling", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__activationContextKeys = Object.keys(ctx).sort();
globalThis.__hasPrivateGateway = [
"project",
"git",
"terminal",
"agents",
"system",
"workState",
"focusedProject",
"pluginWorkspace",
"pluginTask",
"pluginToolchain",
"pluginEvents",
"pluginConfig",
"pluginStorage",
].some((key) => key in 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.__toolingServiceKeys = Object.keys(ctx.services.tooling).sort();
globalThis.__eventServiceKeys = Object.keys(ctx.services.events).sort();
globalThis.__configServiceKeys = Object.keys(ctx.services.config).sort();
globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort();
globalThis.__windowServiceKeys = Object.keys(ctx.services.windows).sort();
}
`);
const { failures } = await loadPlugins(
[
entry({
id: "dev.acme.services",
displayName: "Services",
capabilities: ["tooling"],
bundleUrl: bundle,
}),
],
gateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__activationContextKeys).toEqual([
"commands",
"layouts",
"logger",
"menu",
"pluginDisplayName",
"pluginId",
"pluginRoot",
"services",
"storage",
"subscriptions",
"version",
]);
expect((globalThis as Record<string, unknown>).__hasPrivateGateway).toBe(false);
expect((globalThis as Record<string, unknown>).__serviceKeys).toEqual([
"config",
"events",
"tasks",
"terminal",
"tooling",
"windows",
"workspace",
]);
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
"execTool",
"getCurrentProject",
"getProjectRoot",
"listDirectory",
"queryStructure",
"readBinaryFile",
"readProjectContext",
"readTextFile",
"resolvePath",
"stat",
"updateProjectContext",
"watch",
"writeBinaryFile",
"writeTextFile",
]);
expect((globalThis as Record<string, unknown>).__taskServiceKeys).toEqual([
"attachOutput",
"cancel",
"getCommandStatus",
"getStatus",
"list",
"retry",
"runCommand",
]);
expect((globalThis as Record<string, unknown>).__toolingServiceKeys).toEqual([
"diagnose",
]);
expect((globalThis as Record<string, unknown>).__eventServiceKeys).toEqual([
"subscribe",
]);
expect((globalThis as Record<string, unknown>).__configServiceKeys).toEqual([
"readDocument",
"updateDocument",
]);
expect((globalThis as Record<string, unknown>).__terminalServiceKeys).toEqual([
"close",
"open",
"reattach",
]);
expect((globalThis as Record<string, unknown>).__windowServiceKeys).toEqual([
"open",
]);
});
it("injects services for UI plugins and validates plugin layout windows against declared layouts", async () => {
const calls: unknown[] = [];
const windowGateways = {
...gateways,
window: {
async openViewWindow() {},
async openPluginLayoutWindow(input: unknown) {
calls.push(input);
return {
label: "view-plugin-layout",
url: "index.html?pluginLayout=1",
alreadyOpen: false,
providerPluginDisplayName: "React Plugin",
layoutLabel: "Dashboard",
surface: {
pluginId: "dev.acme.react",
layoutType: "dev.acme.react.dashboard",
state: { tab: "overview" },
},
};
},
async closeViewWindow() {},
async listOpenViewWindows() {
return [];
},
async onViewWindowClosed() {
return () => {};
},
},
} as PluginGatewaySet;
const bundle = dataUrl(`
export async function activate(ctx) {
globalThis.__uiServicesAvailable = Boolean(ctx.services);
globalThis.__pluginWindowResult = await ctx.services.windows.open({
layoutType: "dev.acme.react.dashboard",
state: { tab: "overview" },
});
try {
await ctx.services.windows.open({ layoutType: "dev.acme.react.missing" });
} catch (e) {
globalThis.__pluginWindowValidationError = String(e);
}
}
`);
const { failures } = await loadPlugins(
[
entry({
id: "dev.acme.react",
displayName: "React Plugin",
capabilities: ["ui"],
bundleUrl: bundle,
contributes: {
...emptyContributes(),
layouts: [
{
type: "dev.acme.react.dashboard",
label: "Dashboard",
component: "Dashboard",
},
],
},
}),
],
windowGateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__uiServicesAvailable).toBe(true);
expect((globalThis as Record<string, unknown>).__pluginWindowResult).toMatchObject({
label: "view-plugin-layout",
surface: { layoutType: "dev.acme.react.dashboard" },
});
expect((globalThis as Record<string, unknown>).__pluginWindowValidationError).toMatch(
/undeclared layout/,
);
expect(calls).toEqual([
{
pluginId: "dev.acme.react",
layoutType: "dev.acme.react.dashboard",
state: { tab: "overview" },
},
]);
});
it("injects plugin-owned storage scoped to the activating plugin id", async () => {
const calls: unknown[] = [];
const storageGateways = {
...gateways,
pluginStorage: {
async get(input: unknown) {
calls.push(["get", input]);
return { launches: 1 };
},
async set(input: unknown) {
calls.push(["set", input]);
},
async delete(input: unknown) {
calls.push(["delete", input]);
return true;
},
},
} as PluginGatewaySet;
const bundle = dataUrl(`
export async function activate(ctx) {
globalThis.__storageKeys = Object.keys(ctx.storage).sort();
globalThis.__storedValue = await ctx.storage.get("helloPlugin.state");
await ctx.storage.set("helloPlugin.state", { launches: 2 });
await ctx.storage.delete("helloPlugin.state");
}
`);
const { failures } = await loadPlugins(
[entry({ id: "dev.acme.storage", displayName: "Storage", bundleUrl: bundle })],
storageGateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__storageKeys).toEqual([
"delete",
"get",
"set",
]);
expect((globalThis as Record<string, unknown>).__storedValue).toEqual({ launches: 1 });
expect(calls).toEqual([
["get", { pluginId: "dev.acme.storage", key: "helloPlugin.state" }],
[
"set",
{
pluginId: "dev.acme.storage",
key: "helloPlugin.state",
value: { launches: 2 },
},
],
["delete", { pluginId: "dev.acme.storage", key: "helloPlugin.state" }],
]);
});
it("leaves project-scoped plugins pending without a focused project", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__pendingProjectPluginActivated = ctx.pluginId;
}
`);
const noFocusedProjectGateways = {
...gateways,
focusedProject: {
async getFocusedProject() {
return null;
},
},
} as PluginGatewaySet;
const { registry, failures, pending } = await loadPlugins(
[
entry({
id: "dev.acme.project-only",
displayName: "Project Only",
activationScope: "project",
bundleUrl: bundle,
}),
],
noFocusedProjectGateways,
);
expect(registry.list()).toEqual([]);
expect(failures).toEqual([]);
expect(pending).toEqual([
{
pluginId: "dev.acme.project-only",
displayName: "Project Only",
reason: "En attente d'un projet actif.",
},
]);
expect((globalThis as Record<string, unknown>).__pendingProjectPluginActivated).toBeUndefined();
});
it("activates project-scoped plugins when a project is already focused", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__focusedProjectPluginActivated = ctx.pluginId;
}
`);
const focusedProjectGateways = {
...gateways,
focusedProject: {
async getFocusedProject() {
return { id: "p1", name: "Alpha", root: "/tmp/alpha" };
},
},
} as PluginGatewaySet;
const { registry, failures, pending } = await loadPlugins(
[
entry({
id: "dev.acme.project-focused",
displayName: "Project Focused",
activationScope: "project",
bundleUrl: bundle,
}),
],
focusedProjectGateways,
);
expect(failures).toEqual([]);
expect(pending).toEqual([]);
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.project-focused"]);
expect((globalThis as Record<string, unknown>).__focusedProjectPluginActivated).toBe(
"dev.acme.project-focused",
);
});
it("treats omitted activationScope as app and isolates a project-required activation failure", async () => {
const projectDependentBundle = dataUrl(`
export async function activate(ctx) {
await ctx.services.workspace.getProjectRoot();
}
`);
const healthyBundle = dataUrl(`
export function activate(ctx) {
globalThis.__healthyAppPluginActivated = ctx.pluginId;
}
`);
const noFocusedProjectGateways = {
...gateways,
focusedProject: {
async getFocusedProject() {
return null;
},
},
} as PluginGatewaySet;
const { registry, failures, pending } = await loadPlugins(
[
entry({
id: "dev.acme.default-app-scope",
displayName: "Default App Scope",
capabilities: ["tooling"],
bundleUrl: projectDependentBundle,
}),
entry({
id: "dev.acme.healthy-app",
displayName: "Healthy App",
bundleUrl: healthyBundle,
}),
],
noFocusedProjectGateways,
);
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.healthy-app"]);
expect(failures).toEqual([
{
pluginId: "dev.acme.default-app-scope",
reason: "no current project is focused",
},
]);
expect(pending).toEqual([]);
expect((globalThis as Record<string, unknown>).__healthyAppPluginActivated).toBe(
"dev.acme.healthy-app",
);
});
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",
},
],
slashCommands: [
{
name: "/hello",
shortDescription: "Run the hello-plugin callback",
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([]);
expect(registry.get("com.example.hello-plugin")?.contributes.slashCommands).toEqual([
{
name: "/hello",
shortDescription: "Run the hello-plugin callback",
command: "hello-plugin",
},
]);
await registry.runCommand("com.example.hello-plugin", "hello-plugin");
expect((globalThis as Record<string, unknown>).__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("allows command callbacks declared only by slashCommands", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.commands.registerCommand("dev.acme.explain", () => {
globalThis.__slashOnlyCommandRan = true;
});
}
`);
const { registry, failures } = await loadPlugins(
[
entry({
id: "dev.acme.slash",
displayName: "Slash",
bundleUrl: bundle,
contributes: {
slashCommands: [
{
name: "/explain",
shortDescription: "Explain selection",
command: "dev.acme.explain",
},
],
} as unknown as PluginContributionDto,
}),
],
gateways,
);
expect(failures).toEqual([]);
await registry.runCommand("dev.acme.slash", "dev.acme.explain");
expect((globalThis as Record<string, unknown>).__slashOnlyCommandRan).toBe(true);
});
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: "<unknown-plugin>", reason: expect.stringContaining("bundle URL") },
]);
expect((globalThis as Record<string, unknown>).__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<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("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("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([]);
});
});