- capability manifeste tooling supportée backend + transport runtime catalog - ctx.services publique côté SDK/runtime, gated par tooling - surface honnête: workspace, terminal, et tasks en observation/contrôle uniquement - docs/exemple/tests mis à jour - QA PASS sur feature/sdk-plugin-tooling-build-debug-surface
425 lines
14 KiB
TypeScript
425 lines
14 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("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 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("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.__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<string, unknown>).__serviceKeys).toEqual([
|
|
"tasks",
|
|
"terminal",
|
|
"workspace",
|
|
]);
|
|
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
|
"getCurrentProject",
|
|
"getProjectRoot",
|
|
"readProjectContext",
|
|
"updateProjectContext",
|
|
]);
|
|
expect((globalThis as Record<string, unknown>).__taskServiceKeys).toEqual([
|
|
"attachOutput",
|
|
"cancel",
|
|
"getStatus",
|
|
"list",
|
|
"retry",
|
|
]);
|
|
expect((globalThis as Record<string, unknown>).__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<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("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([]);
|
|
});
|
|
});
|