fix(ticket116): isoler crash plugin hello-plugin + erreur explicite UI

This commit is contained in:
2026-07-31 14:17:56 +02:00
parent e042ced724
commit d4e61a86f0
9 changed files with 417 additions and 64 deletions

View File

@ -75,6 +75,7 @@ describe("listPluginLayoutChoices / PluginLayoutSelectorSection", () => {
menuItems: [],
layouts: [
{ type: "dev.acme.good", label: "Good layout", component: "X" },
null,
{ type: "dev.acme.no-label", label: undefined, component: "X" },
{ type: undefined, label: "No type", component: "X" },
] as unknown as PluginContributionDto["layouts"],

View File

@ -30,6 +30,10 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function objectOrNull(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function finiteOrder(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
@ -42,8 +46,10 @@ export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): Plugin
return registry
.layoutContributions()
.flatMap(({ pluginId, pluginDisplayName, layout }) => {
const type = nonEmptyString(layout.type);
const label = nonEmptyString(layout.label);
const layoutObject = objectOrNull(layout);
if (!layoutObject) return [];
const type = nonEmptyString(layoutObject.type);
const label = nonEmptyString(layoutObject.label);
if (!type || !label) return [];
return [
{
@ -53,8 +59,8 @@ export function listPluginLayoutChoices(registry: PluginRuntimeRegistry): Plugin
...layout,
type,
label,
order: finiteOrder(layout.order),
icon: nonEmptyString(layout.icon),
order: finiteOrder(layoutObject.order),
icon: nonEmptyString(layoutObject.icon),
},
},
];

View File

@ -3,7 +3,7 @@
* acceptance criteria: "ordre déterministe, disabledReason, plugin disabled
* absent, command handler appelé").
*/
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { PluginContributionDto } from "@/domain";
import { PluginRuntimeRegistry, type LoadedPlugin, type WhenContext } from "@/plugins/runtime";
@ -19,11 +19,15 @@ const NO_CONTEXT: WhenContext = {
};
function stubPlugin(pluginId: string, displayName: string, contributes: PluginContributionDto): LoadedPlugin {
const declaredCommandIds = contributes.menuItems.flatMap((item) => {
if (item === null || typeof item !== "object") return [];
return typeof item.command === "string" ? [item.command] : [];
});
return {
pluginId,
displayName,
contributes,
commands: new PluginCommandRegistry(pluginId, new Set(contributes.menuItems.map((i) => i.command))),
commands: new PluginCommandRegistry(pluginId, new Set(declaredCommandIds)),
layouts: new PluginLayoutRegistry(pluginId, new Set(contributes.layouts.map((l) => l.type))),
menu: new PluginMenuRegistry(pluginId),
dispose: async () => {},
@ -67,6 +71,7 @@ describe("resolveTopLevelMenus", () => {
...empty(),
menus: [
{ id: "bad.menu", label: "Good", topLevel: true },
null,
{ id: "bad.empty", label: "", topLevel: true },
{ id: undefined, label: "No id", topLevel: true },
{ id: "bad.no-label", label: undefined, topLevel: true },
@ -167,6 +172,7 @@ describe("resolveMenuItems", () => {
label: "Say Hello",
command: "hello-plugin.sayHello",
},
null,
{
id: "hello-plugin.broken.item",
targetMenuId: "hello-plugin.menu",
@ -199,4 +205,29 @@ describe("resolveMenuItems", () => {
await registry.runCommand("dev.acme", "dev.acme.a.cmd");
expect(ran).toBe(true);
});
it("confines a throwing command handler to the plugin command dispatch", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const registry = new PluginRuntimeRegistry();
const plugin = stubPlugin("dev.acme", "Acme", {
...empty(),
menuItems: [
{ id: "dev.acme.a", targetMenuId: "panels", label: "Open A", command: "dev.acme.a.cmd" },
],
});
plugin.commands.register("dev.acme.a.cmd", () => {
throw new Error("boom");
});
registry.add(plugin);
await expect(registry.runCommand("dev.acme", "dev.acme.a.cmd")).resolves.toBeUndefined();
expect(consoleError).toHaveBeenCalledWith(
'[plugin:dev.acme] command "dev.acme.a.cmd" failed',
expect.any(Error),
);
} finally {
consoleError.mockRestore();
}
});
});

View File

@ -22,6 +22,10 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function objectOrNull(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function finiteOrder(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
@ -44,8 +48,10 @@ export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedT
return registry
.topLevelMenus()
.flatMap(({ pluginId, pluginDisplayName, menu }) => {
const id = nonEmptyString(menu.id);
const label = nonEmptyString(menu.label);
const menuObject = objectOrNull(menu);
if (!menuObject) return [];
const id = nonEmptyString(menuObject.id);
const label = nonEmptyString(menuObject.label);
if (!id || !label) return [];
return [
{
@ -53,8 +59,8 @@ export function resolveTopLevelMenus(registry: PluginRuntimeRegistry): ResolvedT
pluginId,
pluginDisplayName,
label,
icon: nonEmptyString(menu.icon),
order: finiteOrder(menu.order),
icon: nonEmptyString(menuObject.icon),
order: finiteOrder(menuObject.order),
},
];
})
@ -80,13 +86,14 @@ export function resolveMenuItems(
): ResolvedPluginMenuItem[] {
return registry
.menuItems()
.filter(({ item }) => targetMatches(item.targetMenuId, targetMenuId))
.flatMap(({ pluginId, pluginDisplayName, item }) => {
const id = nonEmptyString(item.id);
const label = nonEmptyString(item.label);
const command = nonEmptyString(item.command);
const itemObject = objectOrNull(item);
if (!itemObject || !targetMatches(itemObject.targetMenuId, targetMenuId)) return [];
const id = nonEmptyString(itemObject.id);
const label = nonEmptyString(itemObject.label);
const command = nonEmptyString(itemObject.command);
if (!id || !label || !command) return [];
const result = evaluateWhen(item.when, whenCtx);
const result = evaluateWhen(nonEmptyString(itemObject.when), whenCtx);
return [
{
id,
@ -98,8 +105,8 @@ export function resolveMenuItems(
enabled: result.ok ? result.value : false,
disabledReason: result.ok ? undefined : result.reason,
groupLabel: nonEmptyString(pluginDisplayName) ?? pluginId,
order: finiteOrder(item.order),
iconUrl: nonEmptyString(item.icon),
order: finiteOrder(itemObject.order),
iconUrl: nonEmptyString(itemObject.icon),
} satisfies ResolvedPluginMenuItem,
];
})

View File

@ -195,6 +195,39 @@ describe("loadPlugins", () => {
expect((globalThis as Record<string, unknown>).__helloArchiveCommandRan).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) {

View File

@ -82,7 +82,7 @@ function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
}
function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
const prefix = `[plugin:${entry.id}]`;
const prefix = `[plugin:${safePluginId(entry)}]`;
return {
debug: (message, ...args) => console.debug(prefix, message, ...args),
info: (message, ...args) => console.info(prefix, message, ...args),
@ -105,8 +105,38 @@ function arrayOrEmpty<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
function objectOrEmpty(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function safePluginId(entry: unknown): string {
return nonEmptyString(objectOrEmpty(entry).id) ?? "<unknown-plugin>";
}
function commandIdsFromContributes(contributes: PluginContributionDto): Set<string> {
return new Set(
contributes.menuItems.flatMap((item) => {
const command = objectOrEmpty(item).command;
return nonEmptyString(command) ? [command] : [];
}),
);
}
function layoutTypesFromContributes(contributes: PluginContributionDto): Set<string> {
return new Set(
contributes.layouts.flatMap((layout) => {
const type = objectOrEmpty(layout).type;
return nonEmptyString(type) ? [type] : [];
}),
);
}
function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
const contributes = entry.contributes as Partial<PluginContributionDto> | null | undefined;
const contributes = objectOrEmpty(entry.contributes) as Partial<PluginContributionDto>;
return {
menus: arrayOrEmpty(contributes?.menus),
menuItems: arrayOrEmpty(contributes?.menuItems),
@ -135,32 +165,41 @@ async function loadOne(
entry: PluginRuntimePlugin,
gateways: PluginGatewaySet,
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
const entryObject = objectOrEmpty(entry);
const pluginId = safePluginId(entry);
const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
const version = nonEmptyString(entryObject.version) ?? "";
try {
// The bundle URL is a plugin-scoped, content-hashed local protocol URL
// served by the backend (carnet §1.3) — never a disk path or arbitrary
// remote URL, and the content hash busts the module cache after updates.
const mod: unknown = await import(/* @vite-ignore */ entry.bundleUrl);
const bundleUrl = nonEmptyString(entryObject.bundleUrl);
if (!bundleUrl) {
throw new Error("missing plugin bundle URL");
}
const mod: unknown = await import(/* @vite-ignore */ bundleUrl);
if (!isIdeaPluginModule(mod)) {
return {
failure: {
pluginId: entry.id,
pluginId,
reason: `bundle does not export an "activate(ctx)" function`,
},
};
}
const contributes = normalizeContributes(entry);
const declaredCommandIds = new Set(contributes.menuItems.map((item) => item.command));
const declaredLayoutTypes = new Set(contributes.layouts.map((layout) => layout.type));
const commands = new PluginCommandRegistry(entry.id, declaredCommandIds);
const layouts = new PluginLayoutRegistry(entry.id, declaredLayoutTypes);
const menu = new PluginMenuRegistry(entry.id);
const declaredCommandIds = commandIdsFromContributes(contributes);
const declaredLayoutTypes = layoutTypesFromContributes(contributes);
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
const menu = new PluginMenuRegistry(pluginId);
const subscriptions: Disposable[] = [];
const ctx: IdeaPluginContext = {
pluginId: entry.id,
pluginDisplayName: entry.displayName,
version: entry.version,
pluginId,
pluginDisplayName: displayName,
version,
logger: createPluginLogger(entry),
subscriptions,
commands: createCommandContext(commands),
@ -172,8 +211,8 @@ async function loadOne(
const activation = await mod.activate(ctx);
const plugin: LoadedPlugin = {
pluginId: entry.id,
displayName: entry.displayName,
pluginId,
displayName,
contributes,
commands,
layouts,
@ -188,7 +227,7 @@ async function loadOne(
} catch (e) {
return {
failure: {
pluginId: entry.id,
pluginId,
reason: e instanceof Error ? e.message : String(e),
},
};
@ -208,7 +247,8 @@ export async function loadPlugins(
const registry = new PluginRuntimeRegistry();
const failures: PluginLoadFailure[] = [];
const results = await Promise.all(catalogPlugins.map((entry) => loadOne(entry, gateways)));
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
const results = await Promise.all(entries.map((entry) => loadOne(entry, gateways)));
for (const result of results) {
if ("failure" in result) failures.push(result.failure);
else registry.add(result.plugin);

View File

@ -65,7 +65,14 @@ export class PluginCommandRegistry {
async run(commandId: string, ...args: unknown[]): Promise<void> {
const handler = this.handlers.get(commandId);
if (!handler) return;
await handler(...args);
try {
await handler(...args);
} catch (e) {
console.error(
`[plugin:${this.pluginId}] command "${commandId}" failed`,
e,
);
}
}
has(commandId: string): boolean {