fix(ticket116): isoler crash plugin hello-plugin + erreur explicite UI
This commit is contained in:
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user